// ================================================================================================= // ADOBE SYSTEMS INCORPORATED // Copyright 2006 Adobe Systems Incorporated // All Rights Reserved // // NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the terms // of the Adobe license agreement accompanying it. // ================================================================================================= package com.adobe.xmp.impl; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.util.Locale; import java.util.SimpleTimeZone; import com.adobe.xmp.XMPDateTime; import com.adobe.xmp.XMPError; import com.adobe.xmp.XMPException; /** * Converts between ISO 8601 Strings and Calendar with millisecond resolution. * * @since 16.02.2006 */ public final class ISO8601Converter { /** Hides public constructor */ private ISO8601Converter() { // EMPTY } /** * Converts an ISO 8601 string to an XMPDateTime. * * Parse a date according to ISO 8601 and * http://www.w3.org/TR/NOTE-datetime: * * * Data fields: * * * Note that ISO 8601 does not seem to allow years less than 1000 or greater * than 9999. We allow any year, even negative ones. The year is formatted * as "%.4d". *

* Note: Tolerate missing TZD, assume is UTC. Photoshop 8 writes * dates like this for exif:GPSTimeStamp.
* Note: Tolerate missing date portion, in case someone foolishly * writes a time-only value that way. * * @param iso8601String a date string that is ISO 8601 conform. * @return Returns a Calendar. * @throws XMPException Is thrown when the string is non-conform. */ public static XMPDateTime parse(String iso8601String) throws XMPException { return parse(iso8601String, new XMPDateTimeImpl()); } /** * @param iso8601String a date string that is ISO 8601 conform. * @param binValue an existing XMPDateTime to set with the parsed date * @return Returns an XMPDateTime-object containing the ISO8601-date. * @throws XMPException Is thrown when the string is non-conform. */ public static XMPDateTime parse(String iso8601String, XMPDateTime binValue) throws XMPException { ParameterAsserts.assertNotNull(iso8601String); ParseState input = new ParseState(iso8601String); int value; boolean timeOnly = input.ch(0) == 'T' || (input.length() >= 2 && input.ch(1) == ':' || (input.length() >= 3 && input.ch(2) == ':')); if (!timeOnly) { if (input.ch(0) == '-') { input.skip(); } // Extract the year. value = input.gatherInt("Invalid year in date string", 9999); if (input.hasNext() && input.ch() != '-') { throw new XMPException("Invalid date string, after year", XMPError.BADVALUE); } if (input.ch(0) == '-') { value = -value; } binValue.setYear(value); if (!input.hasNext()) { return binValue; } input.skip(); // Extract the month. value = input.gatherInt("Invalid month in date string", 12); if (input.hasNext() && input.ch() != '-') { throw new XMPException("Invalid date string, after month", XMPError.BADVALUE); } binValue.setMonth(value); if (!input.hasNext()) { return binValue; } input.skip(); // Extract the day. value = input.gatherInt("Invalid day in date string", 31); if (input.hasNext() && input.ch() != 'T') { throw new XMPException("Invalid date string, after day", XMPError.BADVALUE); } binValue.setDay(value); if (!input.hasNext()) { return binValue; } } else { // set default day and month in the year 0000 binValue.setMonth(1); binValue.setDay(1); } if (input.ch() == 'T') { input.skip(); } else if (!timeOnly) { throw new XMPException("Invalid date string, missing 'T' after date", XMPError.BADVALUE); } // Extract the hour. value = input.gatherInt("Invalid hour in date string", 23); if (input.ch() != ':') { throw new XMPException("Invalid date string, after hour", XMPError.BADVALUE); } binValue.setHour(value); // Don't check for done, we have to work up to the time zone. input.skip(); // Extract the minute. value = input.gatherInt("Invalid minute in date string", 59); if (input.hasNext() && input.ch() != ':' && input.ch() != 'Z' && input.ch() != '+' && input.ch() != '-') { throw new XMPException("Invalid date string, after minute", XMPError.BADVALUE); } binValue.setMinute(value); if (input.ch() == ':') { input.skip(); value = input.gatherInt("Invalid whole seconds in date string", 59); if (input.hasNext() && input.ch() != '.' && input.ch() != 'Z' && input.ch() != '+' && input.ch() != '-') { throw new XMPException("Invalid date string, after whole seconds", XMPError.BADVALUE); } binValue.setSecond(value); if (input.ch() == '.') { input.skip(); int digits = input.pos(); value = input.gatherInt("Invalid fractional seconds in date string", 999999999); if (input.ch() != 'Z' && input.ch() != '+' && input.ch() != '-') { throw new XMPException("Invalid date string, after fractional second", XMPError.BADVALUE); } digits = input.pos() - digits; for (; digits > 9; --digits) { value = value / 10; } for (; digits < 9; ++digits) { value = value * 10; } binValue.setNanoSecond(value); } } int tzSign = 0; int tzHour = 0; int tzMinute = 0; if (input.ch() == 'Z') { input.skip(); } else if (input.hasNext()) { if (input.ch() == '+') { tzSign = 1; } else if (input.ch() == '-') { tzSign = -1; } else { throw new XMPException("Time zone must begin with 'Z', '+', or '-'", XMPError.BADVALUE); } input.skip(); // Extract the time zone hour. tzHour = input.gatherInt("Invalid time zone hour in date string", 23); if (input.ch() != ':') { throw new XMPException("Invalid date string, after time zone hour", XMPError.BADVALUE); } input.skip(); // Extract the time zone minute. tzMinute = input.gatherInt("Invalid time zone minute in date string", 59); } // create a corresponding TZ and set it time zone int offset = (tzHour * 3600 * 1000 + tzMinute * 60 * 1000) * tzSign; binValue.setTimeZone(new SimpleTimeZone(offset, "")); if (input.hasNext()) { throw new XMPException( "Invalid date string, extra chars at end", XMPError.BADVALUE); } return binValue; } /** * Converts a Calendar into an ISO 8601 string. * Format a date according to ISO 8601 and http://www.w3.org/TR/NOTE-datetime: *

* * Data fields: * *

* Note: ISO 8601 does not seem to allow years less than 1000 or greater than 9999. * We allow any year, even negative ones. The year is formatted as "%.4d".

* Note: Fix for bug 1269463 (silently fix out of range values) included in parsing. * The quasi-bogus "time only" values from Photoshop CS are not supported. * * @param dateTime an XMPDateTime-object. * @return Returns an ISO 8601 string. */ public static String render(XMPDateTime dateTime) { StringBuffer buffer = new StringBuffer(); // year is rendered in any case, even 0000 DecimalFormat df = new DecimalFormat("0000", new DecimalFormatSymbols(Locale.ENGLISH)); buffer.append(df.format(dateTime.getYear())); if (dateTime.getMonth() == 0) { return buffer.toString(); } // month df.applyPattern("'-'00"); buffer.append(df.format(dateTime.getMonth())); if (dateTime.getDay() == 0) { return buffer.toString(); } // day buffer.append(df.format(dateTime.getDay())); // time, rendered if any time field is not zero if (dateTime.getHour() != 0 || dateTime.getMinute() != 0 || dateTime.getSecond() != 0 || dateTime.getNanoSecond() != 0 || (dateTime.getTimeZone() != null && dateTime.getTimeZone().getRawOffset() != 0)) { // hours and minutes buffer.append('T'); df.applyPattern("00"); buffer.append(df.format(dateTime.getHour())); buffer.append(':'); buffer.append(df.format(dateTime.getMinute())); // seconds and nanoseconds if (dateTime.getSecond() != 0 || dateTime.getNanoSecond() != 0) { double seconds = dateTime.getSecond() + dateTime.getNanoSecond() / 1e9d; df.applyPattern(":00.#########"); buffer.append(df.format(seconds)); } // time zone if (dateTime.getTimeZone() != null) { // used to calculate the time zone offset incl. Daylight Savings long timeInMillis = dateTime.getCalendar().getTimeInMillis(); int offset = dateTime.getTimeZone().getOffset(timeInMillis); if (offset == 0) { // UTC buffer.append('Z'); } else { int thours = offset / 3600000; int tminutes = Math.abs(offset % 3600000 / 60000); df.applyPattern("+00;-00"); buffer.append(df.format(thours)); df.applyPattern(":00"); buffer.append(df.format(tminutes)); } } } return buffer.toString(); } } /** * @since 22.08.2006 */ class ParseState { /** */ private String str; /** */ private int pos = 0; /** * @param str initializes the parser container */ public ParseState(String str) { this.str = str; } /** * @return Returns the length of the input. */ public int length() { return str.length(); } /** * @return Returns whether there are more chars to come. */ public boolean hasNext() { return pos < str.length(); } /** * @param index index of char * @return Returns char at a certain index. */ public char ch(int index) { return index < str.length() ? str.charAt(index) : 0x0000; } /** * @return Returns the current char or 0x0000 if there are no more chars. */ public char ch() { return pos < str.length() ? str.charAt(pos) : 0x0000; } /** * Skips the next char. */ public void skip() { pos++; } /** * @return Returns the current position. */ public int pos() { return pos; } /** * Parses a integer from the source and sets the pointer after it. * @param errorMsg Error message to put in the exception if no number can be found * @param maxValue the max value of the number to return * @return Returns the parsed integer. * @throws XMPException Thrown if no integer can be found. */ public int gatherInt(String errorMsg, int maxValue) throws XMPException { int value = 0; boolean success = false; char ch = ch(pos); while ('0' <= ch && ch <= '9') { value = (value * 10) + (ch - '0'); success = true; pos++; ch = ch(pos); } if (success) { if (value > maxValue) { return maxValue; } else if (value < 0) { return 0; } else { return value; } } else { throw new XMPException(errorMsg, XMPError.BADVALUE); } } }