DateConverter.java

/*****************************************************************************
 * 
 * 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.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.Locale;
import java.util.SimpleTimeZone;
import java.util.TimeZone;
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
{

    public static final DateTimeFormatter DATE_TIME_FORMATTER = new DateTimeFormatterBuilder().parseCaseInsensitive()
            .append(DateTimeFormatter.ISO_LOCAL_DATE_TIME).parseLenient().appendOffset("+HH:MM", "Z").parseStrict()
            .toFormatter();

    /**
     * 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.isBlank()))
        {
            date = date.trim();

            // 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))
                {
                    try
                    {
                        return fromISO8601(date);
                    }
                    catch (DateTimeParseException ex)
                    {
                        throw new IOException(ex);
                    }
                }
                if (date.startsWith("D:"))
                {
                    date = date.substring(2);
                }

                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() == 14 || 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)
            {
                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(String.format(Locale.US, "%04d", 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();
    }

    private static Calendar fromISO8601(String dateString)
    {
        try
        {
            ZonedDateTime zonedDateTime = ZonedDateTime.parse(dateString, DATE_TIME_FORMATTER);
            return GregorianCalendar.from(zonedDateTime);
        }
        catch (DateTimeParseException e)
        {
            LocalDateTime localDateTime = LocalDateTime.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
            return GregorianCalendar.from(localDateTime.atZone(ZoneId.of("UTC")));
        }
    }
}