1// =================================================================================================
2// ADOBE SYSTEMS INCORPORATED
3// Copyright 2006 Adobe Systems Incorporated
4// All Rights Reserved
5//
6// NOTICE:  Adobe permits you to use, modify, and distribute this file in accordance with the terms
7// of the Adobe license agreement accompanying it.
8// =================================================================================================
9
10package com.adobe.xmp.impl;
11
12import java.text.DecimalFormat;
13import java.text.DecimalFormatSymbols;
14import java.util.Locale;
15import java.util.SimpleTimeZone;
16
17import com.adobe.xmp.XMPDateTime;
18import com.adobe.xmp.XMPError;
19import com.adobe.xmp.XMPException;
20
21
22/**
23 * Converts between ISO 8601 Strings and <code>Calendar</code> with millisecond resolution.
24 *
25 * @since   16.02.2006
26 */
27public final class ISO8601Converter
28{
29	/** Hides public constructor */
30	private ISO8601Converter()
31	{
32		// EMPTY
33	}
34
35
36	/**
37	 * Converts an ISO 8601 string to an <code>XMPDateTime</code>.
38	 *
39	 * Parse a date according to ISO 8601 and
40	 * http://www.w3.org/TR/NOTE-datetime:
41	 * <ul>
42	 * <li>YYYY
43	 * <li>YYYY-MM
44	 * <li>YYYY-MM-DD
45	 * <li>YYYY-MM-DDThh:mmTZD
46	 * <li>YYYY-MM-DDThh:mm:ssTZD
47	 * <li>YYYY-MM-DDThh:mm:ss.sTZD
48	 * </ul>
49	 *
50	 * Data fields:
51	 * <ul>
52	 * <li>YYYY = four-digit year
53	 * <li>MM = two-digit month (01=January, etc.)
54	 * <li>DD = two-digit day of month (01 through 31)
55	 * <li>hh = two digits of hour (00 through 23)
56	 * <li>mm = two digits of minute (00 through 59)
57	 * <li>ss = two digits of second (00 through 59)
58	 * <li>s = one or more digits representing a decimal fraction of a second
59	 * <li>TZD = time zone designator (Z or +hh:mm or -hh:mm)
60	 * </ul>
61	 *
62	 * Note that ISO 8601 does not seem to allow years less than 1000 or greater
63	 * than 9999. We allow any year, even negative ones. The year is formatted
64	 * as "%.4d".
65	 * <p>
66	 * <em>Note:</em> Tolerate missing TZD, assume is UTC. Photoshop 8 writes
67	 * dates like this for exif:GPSTimeStamp.<br>
68	 * <em>Note:</em> Tolerate missing date portion, in case someone foolishly
69	 * writes a time-only value that way.
70	 *
71	 * @param iso8601String a date string that is ISO 8601 conform.
72	 * @return Returns a <code>Calendar</code>.
73	 * @throws XMPException Is thrown when the string is non-conform.
74	 */
75	public static XMPDateTime parse(String iso8601String) throws XMPException
76	{
77		return parse(iso8601String, new XMPDateTimeImpl());
78	}
79
80
81	/**
82	 * @param iso8601String a date string that is ISO 8601 conform.
83	 * @param binValue an existing XMPDateTime to set with the parsed date
84	 * @return Returns an XMPDateTime-object containing the ISO8601-date.
85	 * @throws XMPException Is thrown when the string is non-conform.
86	 */
87	public static XMPDateTime parse(String iso8601String, XMPDateTime binValue) throws XMPException
88	{
89		ParameterAsserts.assertNotNull(iso8601String);
90
91		ParseState input = new ParseState(iso8601String);
92		int value;
93
94		boolean timeOnly =
95			 input.ch(0) == 'T'  ||
96			(input.length() >= 2  &&  input.ch(1) == ':'  ||
97			(input.length() >= 3  &&  input.ch(2) == ':'));
98
99		if (!timeOnly)
100		{
101			if (input.ch(0) == '-')
102			{
103				input.skip();
104			}
105
106
107			// Extract the year.
108			value = input.gatherInt("Invalid year in date string", 9999);
109			if (input.hasNext()  &&  input.ch() != '-')
110			{
111				throw new XMPException("Invalid date string, after year", XMPError.BADVALUE);
112			}
113
114			if (input.ch(0) == '-')
115			{
116				value = -value;
117			}
118			binValue.setYear(value);
119			if (!input.hasNext())
120			{
121				return binValue;
122			}
123			input.skip();
124
125
126			// Extract the month.
127			value = input.gatherInt("Invalid month in date string", 12);
128			if (input.hasNext()  &&  input.ch() != '-')
129			{
130				throw new XMPException("Invalid date string, after month", XMPError.BADVALUE);
131			}
132			binValue.setMonth(value);
133			if (!input.hasNext())
134			{
135				return binValue;
136			}
137			input.skip();
138
139
140			// Extract the day.
141			value = input.gatherInt("Invalid day in date string", 31);
142			if (input.hasNext()  &&  input.ch() != 'T')
143			{
144				throw new XMPException("Invalid date string, after day", XMPError.BADVALUE);
145			}
146			binValue.setDay(value);
147			if (!input.hasNext())
148			{
149				return binValue;
150			}
151		}
152		else
153		{
154			// set default day and month in the year 0000
155			binValue.setMonth(1);
156			binValue.setDay(1);
157		}
158
159		if (input.ch() == 'T')
160		{
161			input.skip();
162		}
163		else if (!timeOnly)
164		{
165			throw new XMPException("Invalid date string, missing 'T' after date",
166					XMPError.BADVALUE);
167		}
168
169
170		// Extract the hour.
171		value = input.gatherInt("Invalid hour in date string", 23);
172		if (input.ch() != ':')
173		{
174			throw new XMPException("Invalid date string, after hour", XMPError.BADVALUE);
175		}
176		binValue.setHour(value);
177
178		// Don't check for done, we have to work up to the time zone.
179		input.skip();
180
181
182		// Extract the minute.
183		value = input.gatherInt("Invalid minute in date string", 59);
184		if (input.hasNext()  &&
185			input.ch() != ':' && input.ch() != 'Z' && input.ch() != '+' && input.ch() != '-')
186		{
187			throw new XMPException("Invalid date string, after minute", XMPError.BADVALUE);
188		}
189		binValue.setMinute(value);
190
191		if (input.ch() == ':')
192		{
193			input.skip();
194			value = input.gatherInt("Invalid whole seconds in date string", 59);
195			if (input.hasNext()  &&  input.ch() != '.'  &&  input.ch() != 'Z'  &&
196				input.ch() != '+' && input.ch() != '-')
197			{
198				throw new XMPException("Invalid date string, after whole seconds",
199						XMPError.BADVALUE);
200			}
201			binValue.setSecond(value);
202			if (input.ch() == '.')
203			{
204				input.skip();
205				int digits = input.pos();
206				value = input.gatherInt("Invalid fractional seconds in date string", 999999999);
207				if (input.ch() != 'Z'  &&  input.ch() != '+'  &&  input.ch() != '-')
208				{
209					throw new XMPException("Invalid date string, after fractional second",
210							XMPError.BADVALUE);
211				}
212				digits = input.pos() - digits;
213				for (; digits > 9; --digits)
214				{
215					value = value / 10;
216				}
217				for (; digits < 9; ++digits)
218				{
219					value = value * 10;
220				}
221				binValue.setNanoSecond(value);
222			}
223		}
224
225		int tzSign = 0;
226		int tzHour = 0;
227		int tzMinute = 0;
228		if (input.ch() == 'Z')
229		{
230			input.skip();
231		}
232		else if (input.hasNext())
233		{
234			if (input.ch() == '+')
235			{
236				tzSign = 1;
237			}
238			else if (input.ch() == '-')
239			{
240				tzSign = -1;
241			}
242			else
243			{
244				throw new XMPException("Time zone must begin with 'Z', '+', or '-'",
245						XMPError.BADVALUE);
246			}
247
248			input.skip();
249			// Extract the time zone hour.
250			tzHour = input.gatherInt("Invalid time zone hour in date string", 23);
251			if (input.ch() != ':')
252			{
253				throw new XMPException("Invalid date string, after time zone hour",
254						XMPError.BADVALUE);
255			}
256			input.skip();
257
258			// Extract the time zone minute.
259			tzMinute = input.gatherInt("Invalid time zone minute in date string", 59);
260		}
261
262		// create a corresponding TZ and set it time zone
263		int offset = (tzHour * 3600 * 1000 + tzMinute * 60 * 1000) * tzSign;
264		binValue.setTimeZone(new SimpleTimeZone(offset, ""));
265
266
267		if (input.hasNext())
268		{
269			throw new XMPException(
270				"Invalid date string, extra chars at end", XMPError.BADVALUE);
271		}
272
273		return binValue;
274	}
275
276
277	/**
278	 * Converts a <code>Calendar</code> into an ISO 8601 string.
279	 * Format a date according to ISO 8601 and http://www.w3.org/TR/NOTE-datetime:
280	 * <ul>
281	 * <li>YYYY
282	 * <li>YYYY-MM
283	 * <li>YYYY-MM-DD
284	 * <li>YYYY-MM-DDThh:mmTZD
285	 * <li>YYYY-MM-DDThh:mm:ssTZD
286	 * <li>YYYY-MM-DDThh:mm:ss.sTZD
287	 * </ul>
288	 *
289	 * Data fields:
290	 * <ul>
291	 * <li>YYYY = four-digit year
292	 * <li>MM	 = two-digit month (01=January, etc.)
293	 * <li>DD	 = two-digit day of month (01 through 31)
294	 * <li>hh	 = two digits of hour (00 through 23)
295	 * <li>mm	 = two digits of minute (00 through 59)
296	 * <li>ss	 = two digits of second (00 through 59)
297	 * <li>s	 = one or more digits representing a decimal fraction of a second
298	 * <li>TZD	 = time zone designator (Z or +hh:mm or -hh:mm)
299	 * </ul>
300	 * <p>
301	 * <em>Note:</em> ISO 8601 does not seem to allow years less than 1000 or greater than 9999.
302	 * We allow any year, even negative ones. The year is formatted as "%.4d".<p>
303	 * <em>Note:</em> Fix for bug 1269463 (silently fix out of range values) included in parsing.
304	 * The quasi-bogus "time only" values from Photoshop CS are not supported.
305	 *
306	 * @param dateTime an XMPDateTime-object.
307	 * @return Returns an ISO 8601 string.
308	 */
309	public static String render(XMPDateTime dateTime)
310	{
311		StringBuffer buffer = new StringBuffer();
312
313		// year is rendered in any case, even 0000
314		DecimalFormat df = new DecimalFormat("0000", new DecimalFormatSymbols(Locale.ENGLISH));
315		buffer.append(df.format(dateTime.getYear()));
316		if (dateTime.getMonth() == 0)
317		{
318			return buffer.toString();
319		}
320
321		// month
322		df.applyPattern("'-'00");
323		buffer.append(df.format(dateTime.getMonth()));
324		if (dateTime.getDay() == 0)
325		{
326			return buffer.toString();
327		}
328
329		// day
330		buffer.append(df.format(dateTime.getDay()));
331
332		// time, rendered if any time field is not zero
333		if (dateTime.getHour() != 0  ||
334			dateTime.getMinute() != 0  ||
335			dateTime.getSecond() != 0  ||
336			dateTime.getNanoSecond() != 0  ||
337			(dateTime.getTimeZone() != null  &&  dateTime.getTimeZone().getRawOffset() != 0))
338		{
339			// hours and minutes
340			buffer.append('T');
341			df.applyPattern("00");
342			buffer.append(df.format(dateTime.getHour()));
343			buffer.append(':');
344			buffer.append(df.format(dateTime.getMinute()));
345
346			// seconds and nanoseconds
347			if (dateTime.getSecond() != 0 || dateTime.getNanoSecond() != 0)
348			{
349				double seconds = dateTime.getSecond() + dateTime.getNanoSecond() / 1e9d;
350
351				df.applyPattern(":00.#########");
352				buffer.append(df.format(seconds));
353			}
354
355			// time zone
356			if (dateTime.getTimeZone() != null)
357			{
358				// used to calculate the time zone offset incl. Daylight Savings
359				long timeInMillis = dateTime.getCalendar().getTimeInMillis();
360				int offset = dateTime.getTimeZone().getOffset(timeInMillis);
361				if (offset == 0)
362				{
363					// UTC
364					buffer.append('Z');
365				}
366				else
367				{
368					int thours = offset / 3600000;
369					int tminutes = Math.abs(offset % 3600000 / 60000);
370					df.applyPattern("+00;-00");
371					buffer.append(df.format(thours));
372					df.applyPattern(":00");
373					buffer.append(df.format(tminutes));
374				}
375			}
376		}
377		return buffer.toString();
378	}
379
380
381}
382
383
384/**
385 * @since   22.08.2006
386 */
387class ParseState
388{
389	/** */
390	private String str;
391	/** */
392	private int pos = 0;
393
394
395	/**
396	 * @param str initializes the parser container
397	 */
398	public ParseState(String str)
399	{
400		this.str = str;
401	}
402
403
404	/**
405	 * @return Returns the length of the input.
406	 */
407	public int length()
408	{
409		return str.length();
410	}
411
412
413	/**
414	 * @return Returns whether there are more chars to come.
415	 */
416	public boolean hasNext()
417	{
418		return pos < str.length();
419	}
420
421
422	/**
423	 * @param index index of char
424	 * @return Returns char at a certain index.
425	 */
426	public char ch(int index)
427	{
428		return index < str.length() ?
429			str.charAt(index) :
430			0x0000;
431	}
432
433
434	/**
435	 * @return Returns the current char or 0x0000 if there are no more chars.
436	 */
437	public char ch()
438	{
439		return pos < str.length() ?
440			str.charAt(pos) :
441			0x0000;
442	}
443
444
445	/**
446	 * Skips the next char.
447	 */
448	public void skip()
449	{
450		pos++;
451	}
452
453
454	/**
455	 * @return Returns the current position.
456	 */
457	public int pos()
458	{
459		return pos;
460	}
461
462
463	/**
464	 * Parses a integer from the source and sets the pointer after it.
465	 * @param errorMsg Error message to put in the exception if no number can be found
466	 * @param maxValue the max value of the number to return
467	 * @return Returns the parsed integer.
468	 * @throws XMPException Thrown if no integer can be found.
469	 */
470	public int gatherInt(String errorMsg, int maxValue) throws XMPException
471	{
472		int value = 0;
473		boolean success = false;
474		char ch = ch(pos);
475		while ('0' <= ch  &&  ch <= '9')
476		{
477			value = (value * 10) + (ch - '0');
478			success = true;
479			pos++;
480			ch = ch(pos);
481		}
482
483		if (success)
484		{
485			if (value > maxValue)
486			{
487				return maxValue;
488			}
489			else if (value < 0)
490			{
491				return 0;
492			}
493			else
494			{
495				return value;
496			}
497		}
498		else
499		{
500			throw new XMPException(errorMsg, XMPError.BADVALUE);
501		}
502	}
503}
504
505
506