// =================================================================================================
// 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:
*
* 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:
*
* 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); } } }