blob: 0f1c73cb1118910ca6bb0293dc35adf1ef375427 [file] [log] [blame]
/*****************************************************************************
*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*
****************************************************************************/
package org.apache.xmpbox;
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Locale;
import java.util.SimpleTimeZone;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* This class is used to convert dates to strings and back using the PDF date standards. Date are described in
* PDFReference1.4 section 3.8.2
*
* <p>
* <strong>This is (and will not be) a Java date parsing library and will likely still have limited
* support for various strings as it’s main use case it to parse from PDF date strings.</strong>
* </p>
*
* @author Ben Litchfield
* @author Christopher Oezbek
*
*/
public final class DateConverter
{
// The Date format is supposed to be the PDF_DATE_FORMAT, but not all PDF
// documents
// will use that date, so I have added a couple other potential formats
// to try if the original one does not work.
private static final SimpleDateFormat[] POTENTIAL_FORMATS = new SimpleDateFormat[] {
new SimpleDateFormat("EEEE, dd MMM yyyy hh:mm:ss a"),
new SimpleDateFormat("EEEE, MMM dd, yyyy hh:mm:ss a"),
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"),
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssz"),
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"),
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.S")
};
/**
* According to check-style, Utility classes should not have a public or default constructor.
*/
private DateConverter()
{
}
/**
* This will convert a string to a calendar.
*
* @param date
* The string representation of the calendar.
*
* @return The calendar that this string represents.
*
* @throws IOException
* If the date string is not in the correct format.
*/
public static Calendar toCalendar(String date) throws IOException
{
Calendar retval = null;
if ((date != null) && (date.trim().length() > 0))
{
// these are the default values
int month = 1;
int day = 1;
int hour = 0;
int minute = 0;
int second = 0;
// first string off the prefix if it exists
try
{
SimpleTimeZone zone = null;
if (Pattern.matches("^\\d{4}-\\d{2}-\\d{2}T.*", date))
{
// Assuming ISO860 date string
return fromISO8601(date);
}
else if (date.startsWith("D:"))
{
date = date.substring(2, date.length());
}
date = date.replaceAll("[-:T]", "");
if (date.length() < 4)
{
throw new IOException("Error: Invalid date format '" + date + "'");
}
int year = Integer.parseInt(date.substring(0, 4));
if (date.length() >= 6)
{
month = Integer.parseInt(date.substring(4, 6));
}
if (date.length() >= 8)
{
day = Integer.parseInt(date.substring(6, 8));
}
if (date.length() >= 10)
{
hour = Integer.parseInt(date.substring(8, 10));
}
if (date.length() >= 12)
{
minute = Integer.parseInt(date.substring(10, 12));
}
int timeZonePos = 12;
if (date.length() - 12 > 5 || (date.length() - 12 == 3 && date.endsWith("Z")))
{
second = Integer.parseInt(date.substring(12, 14));
timeZonePos = 14;
}
if (date.length() >= (timeZonePos + 1))
{
char sign = date.charAt(timeZonePos);
if (sign == 'Z')
{
zone = new SimpleTimeZone(0, "Unknown");
}
else
{
int hours = 0;
int minutes = 0;
if (date.length() >= (timeZonePos + 3))
{
if (sign == '+')
{
// parseInt cannot handle the + sign
hours = Integer.parseInt(date.substring((timeZonePos + 1), (timeZonePos + 3)));
}
else
{
hours = -Integer.parseInt(date.substring(timeZonePos, (timeZonePos + 2)));
}
}
if (sign == '+')
{
if (date.length() >= (timeZonePos + 5))
{
minutes = Integer.parseInt(date.substring((timeZonePos + 3), (timeZonePos + 5)));
}
}
else
{
if (date.length() >= (timeZonePos + 4))
{
minutes = Integer.parseInt(date.substring((timeZonePos + 2), (timeZonePos + 4)));
}
}
zone = new SimpleTimeZone(hours * 60 * 60 * 1000 + minutes * 60 * 1000, "Unknown");
}
}
if (zone == null)
{
retval = new GregorianCalendar();
}
else
{
updateZoneId(zone);
retval = new GregorianCalendar(zone);
}
retval.clear();
retval.set(year, month - 1, day, hour, minute, second);
}
catch (NumberFormatException e)
{
// remove the arbitrary : in the timezone. SimpleDateFormat
// can't handle it
if (date.substring(date.length() - 3, date.length() - 2).equals(":")
&& (date.substring(date.length() - 6, date.length() - 5).equals("+") || date.substring(
date.length() - 6, date.length() - 5).equals("-")))
{
// that's a timezone string, remove the :
date = date.substring(0, date.length() - 3) + date.substring(date.length() - 2);
}
for (int i = 0; (retval == null) && (i < POTENTIAL_FORMATS.length); i++)
{
try
{
Date utilDate = POTENTIAL_FORMATS[i].parse(date);
retval = new GregorianCalendar();
retval.setTime(utilDate);
}
catch (ParseException pe)
{
// ignore and move to next potential format
}
}
if (retval == null)
{
// we didn't find a valid date format so throw an exception
throw new IOException("Error converting date:" + date, e);
}
}
}
return retval;
}
/**
* Update the zone ID based on the raw offset. This is either GMT, GMT+hh:mm or GMT-hh:mm, where
* n is between 1 and 14. The highest negative hour is -14, the highest positive hour is 12.
* Zones that don't fit in this schema are set to zone ID "unknown".
*
* @param tz the time zone to update.
*/
private static void updateZoneId(TimeZone tz)
{
int offset = tz.getRawOffset();
char pm = '+';
if (offset < 0)
{
pm = '-';
offset = -offset;
}
int hh = offset / 3600000;
int mm = offset % 3600000 / 60000;
if (offset == 0)
{
tz.setID("GMT");
}
else if (pm == '+' && hh <= 12)
{
tz.setID(String.format(Locale.US, "GMT+%02d:%02d", hh, mm));
}
else if (pm == '-' && hh <= 14)
{
tz.setID(String.format(Locale.US, "GMT-%02d:%02d", hh, mm));
}
else
{
tz.setID("unknown");
}
}
/**
* Convert the date to iso 8601 string format.
*
* @param cal
* The date to convert.
* @return The date represented as an ISO 8601 string.
*/
public static String toISO8601(Calendar cal)
{
return toISO8601(cal, false);
}
/**
* Convert the date to iso 8601 string format.
*
* @param cal The date to convert.
* @param printMillis Print Milliseconds.
* @return The date represented as an ISO 8601 string.
*/
public static String toISO8601(Calendar cal, boolean printMillis)
{
StringBuilder retval = new StringBuilder();
retval.append(cal.get(Calendar.YEAR));
retval.append('-');
retval.append(String.format(Locale.US, "%02d", cal.get(Calendar.MONTH) + 1));
retval.append('-');
retval.append(String.format(Locale.US, "%02d", cal.get(Calendar.DAY_OF_MONTH)));
retval.append('T');
retval.append(String.format(Locale.US, "%02d", cal.get(Calendar.HOUR_OF_DAY)));
retval.append(':');
retval.append(String.format(Locale.US, "%02d", cal.get(Calendar.MINUTE)));
retval.append(':');
retval.append(String.format(Locale.US, "%02d", cal.get(Calendar.SECOND)));
if (printMillis)
{
retval.append('.');
retval.append(String.format(Locale.US, "%03d", cal.get(Calendar.MILLISECOND)));
}
int timeZone = cal.get(Calendar.ZONE_OFFSET) + cal.get(Calendar.DST_OFFSET);
if (timeZone < 0)
{
retval.append('-');
}
else
{
retval.append('+');
}
timeZone = Math.abs(timeZone);
// milliseconds/1000 = seconds; seconds / 60 = minutes; minutes/60 = hours
int hours = timeZone / 1000 / 60 / 60;
int minutes = (timeZone - (hours * 1000 * 60 * 60)) / 1000 / 60;
if (hours < 10)
{
retval.append('0');
}
retval.append(hours);
retval.append(':');
if (minutes < 10)
{
retval.append('0');
}
retval.append(minutes);
return retval.toString();
}
/**
* Get a Calendar from an ISO8601 date string.
*
* @param dateString
* @return the Calendar instance.
*/
private static Calendar fromISO8601(String dateString)
{
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss[.SSS][XXX][zzz]");
// Pattern to test for a time zone string
Pattern timeZonePattern = Pattern.compile(
"[\\d-]*T?[\\d-\\.]([A-Z]{1,4})$|(.*\\d*)([A-Z][a-z]+\\/[A-Z][a-z]+)$"
);
Matcher timeZoneMatcher = timeZonePattern.matcher(dateString);
String timeZoneString = null;
while (timeZoneMatcher.find())
{
for (int i = 1; i <= timeZoneMatcher.groupCount(); i++)
{
if (timeZoneMatcher.group(i) != null)
{
timeZoneString = timeZoneMatcher.group(i);
}
}
}
if (timeZoneString != null)
{
// can't use parseDateTime immediately, first do handling for time that has no seconds
int teeIndex = dateString.indexOf('T');
int tzIndex = dateString.indexOf(timeZoneString);
String toParse = dateString.substring(0, tzIndex);
if (tzIndex - teeIndex == 6)
{
toParse = dateString.substring(0, tzIndex) + ":00";
}
ZonedDateTime zonedDateTime = ZonedDateTime.parse(toParse + timeZoneString, dateTimeFormatter);
return GregorianCalendar.from(zonedDateTime);
}
else
{
// can't use parseDateTime immediately, first do handling for time that has no seconds
int teeIndex = dateString.indexOf('T');
if (teeIndex == -1)
{
ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateString, dateTimeFormatter);
return GregorianCalendar.from(zonedDateTime);
}
int plusIndex = dateString.indexOf('+', teeIndex + 1);
int minusIndex = dateString.indexOf('-', teeIndex + 1);
if (plusIndex == -1 && minusIndex == -1)
{
ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateString, dateTimeFormatter);
return GregorianCalendar.from(zonedDateTime);
}
plusIndex = Math.max(plusIndex, minusIndex);
if (plusIndex - teeIndex == 6)
{
String toParse = dateString.substring(0, plusIndex) + ":00" + dateString.substring(plusIndex);
ZonedDateTime zonedDateTime = ZonedDateTime.parse(toParse, dateTimeFormatter);
return GregorianCalendar.from(zonedDateTime);
}
ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateString, dateTimeFormatter);
return GregorianCalendar.from(zonedDateTime);
}
}
}