# 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.pdfbox.util;

    import java.text.ParsePosition;
    import java.text.SimpleDateFormat;
    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 org.apache.commons.logging.Log;
    import org.apache.commons.logging.LogFactory;

    import org.apache.pdfbox.cos.COSString;

    /*
     * Date format is described in PDF Reference 1.7 section 3.8.2
     * (www.adobe.com/devnet/acrobat/pdfs/pdf_reference_1-7.pdf)
     * and also in PDF 32000-1:2008
     * (http://www.adobe.com/devnet/acrobat/pdfs/PDF32000_2008.pdf))
     * although the latter inexplicably omits the trailing apostrophe.
     *
     * The interpretation of dates without timezones is unclear.
     * The code below assumes that such dates are in UTC+00 (aka GMT).
     * This is in keeping with the PDF Reference's assertion that:
     *      numerical fields default to zero values.
     * However, the Reference does go on to make the cryptic remark:
     *      If no UT information is specified, the relationship of the specified
     *      time to UT is considered to be unknown. Whether or not the time
     *      zone is known, the rest of the date should be specified in local time.
     * I understand this to refer to _creating_ a pdf date value. That is,
     * code that can get the wall clock time and cannot get the timezone
     * should write the wall clock time with a time zone of zero.
     * When _parsing_ a PDF date, the statement talks about "the rest of the date"
     * being local time, thus explicitly excluding the use of the local time
     * for the time zone.
    */

    /**
     * Converts dates to strings and back using the PDF date standard
     * in section 3.8.2 of PDF Reference 1.7.
     *
     * @author Ben Litchfield
     * @author Fred Hansen
     *
     * TODO Move members of this class elsewhere for shared use in pdfbox and xmpbox.
     */
    public final class DateConverter
    {
        private static final Log LOG = LogFactory.getLog(DateConverter.class);

        private DateConverter()
        {
        }

        // milliseconds/1000 = seconds; seconds / 60 = minutes; minutes/60 = hours
        private static final int MINUTES_PER_HOUR = 60;
        private static final int SECONDS_PER_MINUTE = 60;
        private static final int MILLIS_PER_MINUTE = SECONDS_PER_MINUTE*1000;
        private static final int MILLIS_PER_HOUR = MINUTES_PER_HOUR * MILLIS_PER_MINUTE;
        private static final int HALF_DAY = 12 * MINUTES_PER_HOUR * MILLIS_PER_MINUTE, DAY = 2*HALF_DAY;

        /*
         * The Date format is supposed to be the PDF_DATE_FORMAT, but other
         * forms appear. These lists offer alternatives to be tried
         * if parseBigEndianDate fails.
         *
         * The time zone offset generally trails the date string, so it is processed
         * separately with parseTZoffset. (This does not preclude having time
         * zones in the elements below; one does.)
         *
         * Alas, SimpleDateFormat is badly non-reentrant -- it modifies its
         * calendar field (PDFBox-402), so these lists are strings to create
         * SimpleDate format as needed.
         *
         * Some past entries have been elided because they duplicate existing
         * entries. See the API for SimpleDateFormat, which says
         *      "For parsing, the number of pattern letters is ignored
         *      unless it's needed to separate two adjacent fields."
         *
         * toCalendar(String, String[]) tests to see that the entire input text
         * has been consumed. Therefore the ordering of formats is important.
         * If one format begins with the entirety of another, the longer
         * must precede the other in the list.
         *
         * HH is for 0-23 hours and hh for 1-12 hours; an "a" field must follow "hh"
         * Where year is yy, four digit years are accepted
         * and two digit years are converted to four digits in the range
         *      [thisyear-79...thisyear+20]
         */
        private static final String[] ALPHA_START_FORMATS =
        {
            "EEEE, dd MMM yy hh:mm:ss a",
            "EEEE, MMM dd, yy hh:mm:ss a",
            "EEEE, MMM dd, yy 'at' hh:mma", // Acrobat Net Distiller 1.0 for Windows
            "EEEE, MMM dd, yy", // Acrobat Distiller 1.0.2 for Macintosh  && PDFBOX-465
            "EEEE MMM dd, yy HH:mm:ss", // ECMP5
            "EEEE MMM dd HH:mm:ss z yy", // GNU Ghostscript 7.0.7
            "EEEE MMM dd HH:mm:ss yy", // GNU Ghostscript 7.0.7 variant
        };

        private static final String[] DIGIT_START_FORMATS =
        {
            "dd MMM yy HH:mm:ss",  // for 26 May 2000 11:25:00
            "dd MMM yy HH:mm",  // for 26 May 2000 11:25
            "yyyy MMM d",   // ambiguity resolved only by omitting time
            "yyyymmddhh:mm:ss", // test case "200712172:2:3"
            "H:m M/d/yy", // test case "9:47 5/12/2008"
            "M/d/yy HH:mm:ss",
            "M/d/yy HH:mm",
            "M/d/yy",

            // proposed rule that is unreachable due to "dd MMM yy HH:mm:ss"
            //     "yyyy MMM d HH:mm:ss",

            // rules made unreachable by "M/d/yy HH:mm:ss" "M/d/yy HH:mm"  "M/d/yy",
            // (incoming digit strings do not mark themselves as y, m, or d!)
                // "d/MM/yyyy HH:mm:ss", // PDFBOX-164 and PDFBOX-170
                // "M/dd/yyyy hh:mm:ss",
                // "MM/d/yyyy hh:mm:ss",
                // "M/d/yyyy HH:mm:ss",
                // "M/dd/yyyy",
                // "MM/d/yyyy",
                // "M/d/yyyy",
                // "M/d/yyyy HH:mm:ss",
                // "M/d/yy HH:mm:ss",
            // subsumed by big-endian parse
                // "yyyy-MM-dd'T'HH:mm:ss",
                // "yyyy-MM-dd'T'HH:mm:ss",
                // "yyyymmdd hh:mm:ss",
                // "yyyymmdd",
                // "yyyymmddX''00''",  // covers 24 cases
                //    (originally the above ended with '+00''00''';
                //      the first apostrophe quoted the plus,
                //      '' mapped to a single ', and the ''' was invalid)
        };

        /**
         * Converts a Calendar to a string formatted as:
         *     D:yyyyMMddHHmmss#hh'mm'  where # is Z, +, or -.
         *
         * @param cal The date to convert to a string. May be null.
         * The DST_OFFSET is included when computing the output time zone.
         *
         * @return The date as a String to be used in a PDF document,
         *      or null if the cal value is null
         */
        public static String toString(Calendar cal)
        {
            if (cal == null)
            {
                return null;
            }
            String offset = formatTZoffset(cal.get(Calendar.ZONE_OFFSET) +
                                           cal.get(Calendar.DST_OFFSET), "'");
            return String.format(Locale.US, "D:"
                    + "%1$4tY%1$2tm%1$2td"   // yyyyMMdd
                    + "%1$2tH%1$2tM%1$2tS"   // HHmmss
                    + "%2$s"                 // time zone
                    + "'",                   // trailing apostrophe
                cal, offset);
        }

        /**
         * Converts the date to ISO 8601 string format:
         *     yyyy-mm-ddThh:MM:ss#hh:mm    (where '#" is '+' or '-').
         *
         * @param cal The date to convert.  Must not be null.
         * The DST_OFFSET is included in the output value.
         *
         * @return The date represented as an ISO 8601 string.
         */
        public static String toISO8601(Calendar cal)
        {
            String offset = formatTZoffset(cal.get(Calendar.ZONE_OFFSET) +
                                           cal.get(Calendar.DST_OFFSET), ":");
            return String.format(Locale.US,
                    "%1$4tY"                  // yyyy
                    + "-%1$2tm"               // -mm  (%tm adds one to cal month value)
                    + "-%1$2td"               // -dd  (%tm adds one to cal month value)
                    + "T"                     // T
                    + "%1$2tH:%1$2tM:%1$2tS"  // HHmmss
                    + "%2$s",                 // time zone
                cal, offset);
        }

        /*
         * Constrain a timezone offset to the range [-14:00 thru +14:00].
         * by adding or subtracting multiples of a full day.
         */
        private static int restrainTZoffset(long proposedOffset)
        {
            if (proposedOffset <= 14 * MILLIS_PER_HOUR && proposedOffset >= -14 * MILLIS_PER_HOUR)
            {
                // https://www.w3.org/TR/xmlschema-2/#dateTime-timezones
                // Timezones between 14:00 and -14:00 are valid
                return (int) proposedOffset;
            }
            // Constrain a timezone offset to the range  [-11:59 thru +12:00].
            proposedOffset = ((proposedOffset + HALF_DAY) % DAY + DAY) % DAY;
            if (proposedOffset == 0)
            {
                return HALF_DAY;
            }
            // 0 <= proposedOffset < DAY
            proposedOffset = (proposedOffset - HALF_DAY) % HALF_DAY;
            // -HALF_DAY < proposedOffset < HALF_DAY
            return (int)proposedOffset;
        }

        /*
         * Formats a time zone offset as #hh^mm
         * where # is + or -, hh is hours, ^ is a separator, and mm is minutes.
         * Any separator may be specified by the second argument;
         * the usual values are ":" (ISO 8601), "" (RFC 822), and "'" (PDF).
         * The returned value is constrained to the range -11:59 ... 11:59.
         * For offset of 0 millis, the String returned is "+00^00", never "Z".
         * To get a "general" offset in form GMT#hh:mm, write
         *      "GMT"+DateConverter.formatTZoffset(offset, ":");
         *
         * Take thought in choosing the source for the millis value.
         * It can come from calendarValue.getTimeZone() or from
         * calendarValue.get(Calendar.ZONE_OFFSET).  If a TimeZone was created
         * from a valid time zone ID, then it may have a daylight savings rule.
         * (As of July 4, 2013, the data base at http://www.iana.org/time-zones
         * recognized 629 time zone regions. But a TimeZone created as
         *      new SimpleTimeZone(millisOffset, "ID"),
         * will not have a daylight savings rule. (Not even if there is a
         * known time zone with the given ID. To get the TimeZone named "xDT"
         * with its DST rule, use an ID of EST5EDT, CST6CDT, MST7MDT, or PST8PDT.
         *
         * When parsing PDF dates, the incoming values DOES NOT have a TIMEZONE value.
         * At most it has an OFFSET value like -04'00'. It is generally impossible to
         * determine what TIMEZONE corresponds to a given OFFSET. If the date is
         * in the summer when daylight savings is in effect, an offset of -0400
         * might correspond to any one of the 38 regions (of 53) with standard time
         * offset -0400 and no daylight saving. Or it might correspond to
         * any one of the 31 regions (out of 43) that observe daylight savings
         * and have standard time offset of -0500.
         *
         * If a Calendar has not been assigned a TimeZone with setTimeZone(),
         * it will have by default the local TIMEZONE, not just the OFFSET.  In the
         * USA, this TimeZone will have a daylight savings rule.
         *
         * The offset assigned with calVal.set(Calendar.ZONE_OFFSET) differs
         * from the offset in the TimeZone set by Calendar.setTimeZone(). Example:
         * Suppose my local TimeZone is America/New_York. It has an offset of -05'00'.
         * And suppose I set a GregorianCalendar's ZONE_OFFSET to -07'00'
         *     calVal = new GregorianCalendar();   // TimeZone is the local default
         *     calVal.set(Calendar.ZONE_OFFSET, -7* MILLIS_PER_HOUR);
         * Four different offsets can be computed from calVal:
         *     calVal.get(Calendar.ZONE_OFFSET)  =>  -07:00
         *     calVal.get(Calendar.ZONE_OFFSET) + calVal.get(Calendar.DST_OFFSET) => -06:00
         *     calVal.getTimeZone().getRawOffset()  =>  -05:00
         *     calVal.getTimeZone().getOffset(calVal.getTimeInMillis())  =>  -04:00
         *
         * Which is correct??? I dunno, though setTimeZone() does seem to affect
         * ZONE_OFFSET, and not vice versa.  One cannot even test whether TimeZone
         * or ZONE_OFFSET has been set; both have been set by initialization code.
         * TimeZone is initialized to the local default time zone
         * and ZONE_OFFSET is set from it.
         *
         * My choice in this DateConverter class has been to set the
         * initial TimeZone of a GregorianCalendar to GMT. Thereafter
         * the TimeZone is modified with {@link #adjustTimeZoneNicely}.
         *
         * package-private for testing
         */
        static String formatTZoffset(long millis, String sep)
        {
            SimpleDateFormat sdf = new SimpleDateFormat("Z", Locale.ENGLISH); // #hhmm
            sdf.setTimeZone(new SimpleTimeZone(restrainTZoffset(millis),"unknown"));
            String tz = sdf.format(new Date());
            return tz.substring(0,3) + sep + tz.substring(3);
        }

         /*
         * Parses an integer from a string, starting at and advancing a ParsePosition.
         * Returns The integer that was at the given parse position, or the remedy value
         * if no digits were found.
         *
         * The ParsePosition will be incremented by the number of digits found, but no
         * more than maxlen. That is, the ParsePosition will advance across at most
         * maxlen initial digits in text. The error index is ignored and unchanged.
         *
         * maxlen is the maximum length of the integer to parse, usually 2, but 4 for
         * year fields. If the field of length maxlen begins with a digit, but contains
         * a non-digit, no error is signaled and the integer value is returned.
         */
        private static int parseTimeField(String text, ParsePosition where, int maxlen, int remedy)
        {
            if (text == null)
            {
                return remedy;
            }
            // it would seem that DecimalFormat.parse() would be simpler;
            // but that class blithely ignores setMaximumIntegerDigits
            int retval = 0;
            int index = where.getIndex();
            int limit = index + Math.min(maxlen, text.length()-index);
            for (; index < limit; index++)
            {
                // convert digit to integer
                int cval = text.charAt(index) - '0';
                // test to see if we got a digit
                if (cval < 0 || cval > 9)
                {
                    // no digit at index
                    break;
                }
                // append the digit to the return value
                retval = retval * 10 + cval;
            }
            if (index == where.getIndex())
            {
                return remedy;
            }
            where.setIndex(index);
            return retval;
        }

        /*
         * Advances the ParsePosition past any and all the characters that match
         * those in the optionals list. In particular, a space will skip all spaces.
         *
         * The start value is incremented by the number of optionals found. The error
         * index is ignored and unchanged.
         *
         * Returns the last non-space character passed over (even if space is not in
         * the optionals list.)
         */
        private static char skipOptionals(String text, ParsePosition where, String optionals)
        {
            char retval = ' ';
            char currch;
            while (where.getIndex() < text.length() &&
                   optionals.indexOf((currch = text.charAt(where.getIndex()))) >= 0)
            {
                retval = (currch != ' ') ? currch : retval;
                where.setIndex(where.getIndex() + 1);
            }
            return retval;
        }

        /*
         * If the victim string is at the given position in the text, this method
         * advances the position past that string.
         *
         * `where` is the initial position to look at. After return, this will have
         * been incremented by the length of the victim if it was found. The error
         * index is ignored and unchanged.
         */
        private static boolean skipString(String text, String victim, ParsePosition where)
        {
            if (text.startsWith(victim, where.getIndex()))
            {
                where.setIndex(where.getIndex()+victim.length());
                return true;
            }
            return false;
        }

        /*
         * Construct a new GregorianCalendar and set defaults.
         * Locale is ENGLISH.
         * TimeZone is "UTC" (zero offset and no DST).
         * Parsing is NOT lenient. Milliseconds are zero.
         *
         * package-private for testing
         */
        static GregorianCalendar newGreg()
        {
            GregorianCalendar retCal = new GregorianCalendar(new SimpleTimeZone(0, "UTC"), Locale.ENGLISH);
            retCal.setLenient(false);
            retCal.set(Calendar.MILLISECOND, 0);
            return retCal;
        }

        /*
         * Install a TimeZone on a GregorianCalendar without changing the
         * hours value. A plain GregorianCalendat.setTimeZone()
         * adjusts the Calendar.HOUR value to compensate. This is *BAD*
         * (not to say *EVIL*) when we have already set the time.
         */
        private static void adjustTimeZoneNicely(GregorianCalendar cal, TimeZone tz)
        {
            cal.setTimeZone(tz);
            int offset = (cal.get(Calendar.ZONE_OFFSET) + cal.get(Calendar.DST_OFFSET)) /
                    MILLIS_PER_MINUTE;
            cal.add(Calendar.MINUTE, -offset);
        }

        /*
         * Parses the end of a date string for a time zone and, if one is found,
         * sets the time zone of the GregorianCalendar. Otherwise the calendar
         * time zone is unchanged.
         *
         * The text is parsed as
         *      (Z|GMT|UTC)? [+- ]* h [': ]? m '?
         * where the leading String is optional, h is two digits by default,
         * but may be a single digit if followed by one of space, apostrophe,
         * colon, or the end of string. Similarly, m is one or two digits.
         * This scheme accepts the format of PDF, RFC 822, and ISO8601.
         * If none of these applies (as for a time zone name), we try
         * TimeZone.getTimeZone().
         *
         * Scanning begins at where.index. After success, the returned index
         * is that of the next character after the recognized string.
         *
         * package-private for testing
         */
        static boolean parseTZoffset(String text, GregorianCalendar cal,
                                            ParsePosition initialWhere)
        {
            ParsePosition where = new ParsePosition(initialWhere.getIndex());
            TimeZone tz = new SimpleTimeZone(0, "GMT");
            int tzHours, tzMin;
            char sign = skipOptionals(text, where, "Z+- ");
            boolean hadGMT = (sign == 'Z' || skipString(text, "GMT", where) ||
                             skipString(text, "UTC", where));
            sign = (!hadGMT) ? sign : skipOptionals(text, where, "+- ");

            tzHours = parseTimeField(text, where, 2, -999);
            skipOptionals(text, where, "\': ");
            tzMin = parseTimeField(text, where, 2, 0);
            skipOptionals(text, where, "\' ");

            if (tzHours != -999)
            {
                // we parsed a time zone in default format
                int hrSign = (sign == '-' ? -1 : 1);
                tz.setRawOffset(restrainTZoffset(hrSign * (tzHours * (long) MILLIS_PER_HOUR +
                                                           tzMin * (long) MILLIS_PER_MINUTE)));
                updateZoneId(tz);
            }
            else if ( ! hadGMT)
            {
                // try to process as a name; "GMT" or "UTC" has already been processed
                String tzText = text.substring(initialWhere.getIndex()).trim();
                tz = TimeZone.getTimeZone(tzText);
                // getTimeZone returns "GMT" for unknown ids
                if ("GMT".equals(tz.getID()))
                {
                    // no timezone in text, cal amd initialWhere are unchanged
                    return false;
                }
                else
                {
                    // we got a tz by name; use it
                    where.setIndex(text.length());
                }
            }
            adjustTimeZoneNicely(cal, tz);
            initialWhere.setIndex(where.getIndex());
            return true;
        }

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

        /*
         * Parses a big-endian date: year month day hour min sec.
         * The year must be four digits. Other fields may be adjacent
         * and delimited by length or they may follow appropriate delimiters.
         *     year [ -/]* month [ -/]* dayofmonth [ T]* hour [:] min [:] sec [.secFraction]
         * If any numeric field is omitted, all following fields must also be omitted.
         * No time zone is processed.
         *
         * Ambiguous dates can produce unexpected results. For example:
         *      1970 12 23:08 will parse as 1970 December 23 00:08:00
         *
         * The parse begins at `where, on return the index
         * is advanced to just beyond the last character processed.
         * The error index is ignored and unchanged.
         */
        private static GregorianCalendar parseBigEndianDate(String text,
                ParsePosition initialWhere)
        {
            ParsePosition where = new ParsePosition(initialWhere.getIndex());
            int year = parseTimeField(text, where, 4, 0);
            if (where.getIndex() != 4 + initialWhere.getIndex())
            {
                return null;
            }
            skipOptionals(text, where, "/- ");
            int month = parseTimeField(text, where, 2, 1) - 1; // Calendar months are 0...11
            skipOptionals(text, where, "/- ");
            int day = parseTimeField(text, where, 2, 1);
            skipOptionals(text, where, " T");
            int hour = parseTimeField(text, where, 2, 0);
            skipOptionals(text, where, ": ");
            int minute = parseTimeField(text, where, 2, 0);
            skipOptionals(text, where, ": ");
            int second = parseTimeField(text, where, 2, 0);
            char nextC = skipOptionals(text, where, ".");
            if (nextC == '.')
            {
                // fractions of a second: skip up to 19 digits
                parseTimeField(text, where, 19, 0);
            }

            GregorianCalendar dest = newGreg();
            try
            {
                dest.set(year, month, day, hour, minute, second);
                // trigger limit tests
                dest.getTimeInMillis();
            }
            catch (IllegalArgumentException ill)
            {
                LOG.debug("Couldn't parse arguments text:" + text + " initialWhere:" + initialWhere, ill);
                return  null;
            }
            initialWhere.setIndex(where.getIndex());
            skipOptionals(text, initialWhere, " ");
            // dest has at least a year value
            return dest;
        }

        /*
         * See if text can be parsed as a date according to any of a list of
         * formats. The time zone may be included as part of the format, or
         * omitted in favor of later testing for a trailing time zone.
         *
         * The parse starts at `where`, upon return it will have been
         * incremented to refer to the next non-space character after the date.
         * If no date was found, the value is unchanged.
         * The error index is ignored and unchanged.
         *
         * If there is a failure to find a date, or the GregorianCalendar
         * for the date that was found. Unless a time zone was
         * part of the format, the time zone will be GMT+0
         */
        private static GregorianCalendar parseSimpleDate(String text, String[] fmts,
                ParsePosition initialWhere)
        {
            for(String fmt : fmts)
            {
                ParsePosition where = new ParsePosition(initialWhere.getIndex());
                SimpleDateFormat sdf = new SimpleDateFormat(fmt, Locale.ENGLISH);
                GregorianCalendar retCal = newGreg();
                sdf.setCalendar(retCal);
                if (sdf.parse(text, where) != null)
                {
                    initialWhere.setIndex(where.getIndex());
                    skipOptionals(text, initialWhere, " ");
                    return retCal;
                }
            }
            return null;
        }

        /*
         * Parses a String to see if it begins with a date, and if so,
         * returns that date. The date must be strictly correct--no
         * field may exceed the appropriate limit.
         * (That is, the Calendar has setLenient(false).)
         * Skips initial spaces, but does NOT check for "D:"
         *
         * The scan first tries parseBigEndianDate and parseTZoffset
         * and then tries parseSimpleDate with appropriate formats,
         * again followed by parseTZoffset. If at any stage the entire
         * text is consumed, that date value is returned immediately.
         * Otherwise the date that consumes the longest initial part
         * of the text is returned.
         *
         * - PDF format dates are among those recognized by parseBigEndianDate.
         * - The formats tried are alphaStartFormats or digitStartFormat and
         * any listed in the value of moreFmts.
         */
        private static Calendar parseDate(String text, ParsePosition initialWhere)
        {
            if (text == null || text.isEmpty() || "D:".equals(text.trim()))
            {
                return null;
            }

            // remember longestr date string
            int longestLen = -999999;
            // theorem: the above value will never be used
            // proof: longestLen is only used if longestDate is not null

            GregorianCalendar longestDate = null; // null says no date found yet
            int whereLen;   // tempcopy of where.getIndex()

            ParsePosition where = new ParsePosition(initialWhere.getIndex());
            // check for null (throws exception) and trim off surrounding spaces
            skipOptionals(text, where, " ");
            int startPosition = where.getIndex();

            // try big-endian parse
            GregorianCalendar retCal = parseBigEndianDate(text, where);
            // check for success and a timezone
            if (retCal != null && (where.getIndex() == text.length() ||
                                   parseTZoffset(text, retCal, where)))
            {
                // if text is fully consumed, return the date else remember it and its length
                whereLen = where.getIndex();
                if (whereLen == text.length())
                {
                    initialWhere.setIndex(whereLen);
                    return retCal;
                }
                longestLen = whereLen;
                longestDate = retCal;
            }

            // try one of the sets of standard formats
            where.setIndex(startPosition);
            String [] formats
                    = Character.isDigit(text.charAt(startPosition))
                    ? DIGIT_START_FORMATS
                    : ALPHA_START_FORMATS;
            retCal = parseSimpleDate(text, formats, where);
            // check for success and a timezone
            if (retCal != null &&
                    (where.getIndex() == text.length() ||
                     parseTZoffset(text, retCal, where)))
            {
                // if text is fully consumed, return the date else remember it and its length
                whereLen = where.getIndex();
                if (whereLen == text.length())
                {
                    initialWhere.setIndex(whereLen);
                    return retCal;
                }
                if (whereLen > longestLen)
                {
                    longestLen = whereLen;
                    longestDate = retCal;
                }
            }

            if (longestDate != null)
            {
                initialWhere.setIndex(longestLen);
                return longestDate;
            }
            return retCal;
        }

        /**
         * Returns the Calendar for a given COS string containing a date,
         * or {@code null} if it cannot be parsed.
         *
         * The returned value will have 0 for DST_OFFSET.
         *
         * @param text A COS string containing a date.
         * @return The Calendar that the text string represents, or {@code null} if it cannot be parsed.
         */
        public static Calendar toCalendar(COSString text)
        {
            if (text == null)
            {
                return null;
            }
            return toCalendar(text.getString());
        }

        /**
         * Returns the Calendar for a given string containing a date,
         * or {@code null} if it cannot be parsed.
         *
         * The returned value will have 0 for DST_OFFSET.
         *
         * @param text A COS string containing a date.
         * @return The Calendar that the text string represents, or {@code null} if it cannot be parsed.
         */
        public static Calendar toCalendar(String text)
        {
            if (text == null || text.trim().isEmpty())
            {
                return null;
            }

            ParsePosition where = new ParsePosition(0);
            skipOptionals(text, where, " ");
            skipString(text, "D:", where);
            Calendar calendar = parseDate(text, where);

            if (calendar == null || where.getIndex() != text.length())
            {
                // the date string is invalid
                return null;
            }
            return calendar;
        }
    }