blob: b38bb16ee93c74b72786f808a0dd8a127dfcb718 [file] [log] [blame]
// =================================================================================================
// 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 <code>Calendar</code> with millisecond resolution.
*
* @since 16.02.2006
*/
public final class ISO8601Converter
{
/** Hides public constructor */
private ISO8601Converter()
{
// EMPTY
}
/**
* Converts an ISO 8601 string to an <code>XMPDateTime</code>.
*
* Parse a date according to ISO 8601 and
* http://www.w3.org/TR/NOTE-datetime:
* <ul>
* <li>YYYY
* <li>YYYY-MM
* <li>YYYY-MM-DD
* <li>YYYY-MM-DDThh:mmTZD
* <li>YYYY-MM-DDThh:mm:ssTZD
* <li>YYYY-MM-DDThh:mm:ss.sTZD
* </ul>
*
* Data fields:
* <ul>
* <li>YYYY = four-digit year
* <li>MM = two-digit month (01=January, etc.)
* <li>DD = two-digit day of month (01 through 31)
* <li>hh = two digits of hour (00 through 23)
* <li>mm = two digits of minute (00 through 59)
* <li>ss = two digits of second (00 through 59)
* <li>s = one or more digits representing a decimal fraction of a second
* <li>TZD = time zone designator (Z or +hh:mm or -hh:mm)
* </ul>
*
* 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".
* <p>
* <em>Note:</em> Tolerate missing TZD, assume is UTC. Photoshop 8 writes
* dates like this for exif:GPSTimeStamp.<br>
* <em>Note:</em> 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 <code>Calendar</code>.
* @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 <code>Calendar</code> into an ISO 8601 string.
* Format a date according to ISO 8601 and http://www.w3.org/TR/NOTE-datetime:
* <ul>
* <li>YYYY
* <li>YYYY-MM
* <li>YYYY-MM-DD
* <li>YYYY-MM-DDThh:mmTZD
* <li>YYYY-MM-DDThh:mm:ssTZD
* <li>YYYY-MM-DDThh:mm:ss.sTZD
* </ul>
*
* Data fields:
* <ul>
* <li>YYYY = four-digit year
* <li>MM = two-digit month (01=January, etc.)
* <li>DD = two-digit day of month (01 through 31)
* <li>hh = two digits of hour (00 through 23)
* <li>mm = two digits of minute (00 through 59)
* <li>ss = two digits of second (00 through 59)
* <li>s = one or more digits representing a decimal fraction of a second
* <li>TZD = time zone designator (Z or +hh:mm or -hh:mm)
* </ul>
* <p>
* <em>Note:</em> 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".<p>
* <em>Note:</em> 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);
}
}
}