LCOV - code coverage report
Current view: top level - src/document - pdf_date_parser.dart Coverage Total Hit
Test: lcov.info Lines: 97.4 % 38 37
Test Date: 2026-08-24 04:28:45 Functions: - 0 0

            Line data    Source code
       1              : // Copyright 2026 The Authors.
       2              : //
       3              : // Licensed under the Apache License, Version 2.0 (the "License");
       4              : // you may not use this file except in compliance with the License.
       5              : // You may obtain a copy of the License at
       6              : //
       7              : //     https://www.apache.org/licenses/LICENSE-2.0
       8              : //
       9              : // Unless required by applicable law or agreed to in writing, software
      10              : // distributed under the License is distributed on an "AS IS" BASIS,
      11              : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12              : // See the License for the specific language governing permissions and
      13              : // limitations under the License.
      14              : 
      15              : // PDF date format parser.
      16              : //
      17              : // PDF dates use the format: D:YYYYMMDDHHmmSSOHH'mm'
      18              : //   D:     — optional prefix
      19              : //   YYYY   — 4-digit year (required)
      20              : //   MM     — 2-digit month (01–12), default 01
      21              : //   DD     — 2-digit day (01–31), default 01
      22              : //   HH     — 2-digit hour (00–23), default 00
      23              : //   mm     — 2-digit minute (00–59), default 00
      24              : //   SS     — 2-digit second (00–59), default 00
      25              : //   O      — timezone offset sign: '+', '-', or 'Z'
      26              : //   HH'mm' — UTC offset hours and minutes (quoted), e.g. "05'30'" for +05:30
      27              : //
      28              : // Real-world PDFs deviate: the D: prefix may be missing, the string may be
      29              : // truncated after any component, and the offset separator is sometimes ':'.
      30              : 
      31              : import 'pdf_types.dart';
      32              : 
      33              : /// Parses PDF date strings into [PdfDate] values.
      34              : ///
      35              : /// This is a hand-rolled parser because Dart's [DateTime.parse] does not
      36              : /// handle the PDF date format `D:YYYYMMDDHHmmSSOHH'mm'`.
      37              : ///
      38              : /// The parser is tolerant of real-world deviations:
      39              : /// - Missing `D:` prefix.
      40              : /// - Truncation after any component (year is the minimum required).
      41              : /// - `Z` for UTC timezone instead of a signed offset.
      42              : /// - Offset separator as `'` or `:`.
      43              : class PdfDateParser {
      44              :   // Private constructor — all methods are static.
      45              :   // coverage:ignore-next-line
      46            0 :   const PdfDateParser._();
      47              : 
      48              :   /// Parses [raw] into a [PdfDate].
      49              :   ///
      50              :   /// If [raw] is empty or `null`, returns `null`. If parsing fails, returns a
      51              :   /// [PdfDate] with [PdfDate.value] set to `null` and [PdfDate.raw] set to
      52              :   /// the original string so callers can inspect or log it.
      53            4 :   static PdfDate? parse(String? raw) {
      54            4 :     if (raw == null || raw.isEmpty) return null;
      55            8 :     return PdfDate(raw: raw, value: _tryParse(raw));
      56              :   }
      57              : 
      58              :   /// Attempts to parse a PDF date string, returning `null` on failure.
      59            4 :   static DateTime? _tryParse(String raw) {
      60              :     // Remove the optional 'D:' prefix (case-insensitive for robustness).
      61              :     String s = raw;
      62            5 :     if (s.startsWith('D:') || s.startsWith('d:')) {
      63            4 :       s = s.substring(2);
      64              :     }
      65              : 
      66              :     // Remove any trailing whitespace or null characters that some tools add.
      67            4 :     s = s.trim();
      68              : 
      69              :     // The year (4 digits) is the minimum required component.
      70            8 :     if (s.length < 4) return null;
      71              : 
      72              :     try {
      73              :       // Parse each date/time component with fallback defaults.
      74            4 :       final year = _parseInt(s, 0, 4);
      75              :       if (year == null) return null;
      76              : 
      77           12 :       final month = s.length >= 6 ? (_parseInt(s, 4, 6) ?? 1) : 1;
      78           12 :       final day = s.length >= 8 ? (_parseInt(s, 6, 8) ?? 1) : 1;
      79           12 :       final hour = s.length >= 10 ? (_parseInt(s, 8, 10) ?? 0) : 0;
      80           12 :       final minute = s.length >= 12 ? (_parseInt(s, 10, 12) ?? 0) : 0;
      81           12 :       final second = s.length >= 14 ? (_parseInt(s, 12, 14) ?? 0) : 0;
      82              : 
      83              :       // Validate ranges to catch obviously invalid dates.
      84            8 :       if (month < 1 || month > 12) return null;
      85            8 :       if (day < 1 || day > 31) return null;
      86           12 :       if (hour > 23 || minute > 59 || second > 59) return null;
      87              : 
      88              :       // Parse the optional UTC offset starting at position 14.
      89              :       // The offset character is '+', '-', or 'Z'.
      90              :       //
      91              :       // We always produce a UTC DateTime. The approach:
      92              :       //   1. Build the time as UTC.namedConstructor(year, month, day, ...).
      93              :       //   2. Subtract the offset to convert from local-zone to UTC.
      94              :       //      e.g. 12:00 +05:30 → subtract 5h30m → 06:30 UTC.
      95              :       //      e.g. 12:00 -08:00 → subtract -8h   → 20:00 UTC.
      96              :       Duration offset = Duration.zero;
      97            8 :       if (s.length > 14) {
      98            3 :         final sign = s[14];
      99            5 :         if (sign == 'Z' || sign == 'z') {
     100              :           // Explicit UTC — offset is zero; the components are already UTC.
     101              :           offset = Duration.zero;
     102            3 :         } else if (sign == '+' || sign == '-') {
     103              :           // Parse offset hours and minutes. The format is OHH'mm' or OHH:mm.
     104              :           // After the sign character, we expect up to 4 more digits with an
     105              :           // optional separator (apostrophe or colon) between hours and minutes.
     106            2 :           final offsetStr = s.substring(15); // everything after the sign
     107            2 :           final offsetHours = _parseOffsetComponent(offsetStr, 0);
     108            2 :           final offsetMinutes = _parseOffsetComponent(offsetStr, 2);
     109            4 :           final totalMinutes = (offsetHours ?? 0) * 60 + (offsetMinutes ?? 0);
     110              :           // A +05:30 offset means the local time is 5h30m ahead of UTC,
     111              :           // so we subtract the offset to get UTC.
     112            2 :           offset = Duration(
     113            3 :             minutes: sign == '+' ? totalMinutes : -totalMinutes,
     114              :           );
     115              :         }
     116              :         // Unknown sign character → treat as UTC (best-effort).
     117              :       }
     118              : 
     119              :       // Build the parsed components as a UTC DateTime, then subtract the
     120              :       // local-to-UTC offset. Using DateTime.utc() avoids the host timezone
     121              :       // being applied, which would produce incorrect results on machines in
     122              :       // non-UTC timezones.
     123            4 :       final asUtc = DateTime.utc(year, month, day, hour, minute, second);
     124            4 :       return asUtc.subtract(offset);
     125              :     } catch (_) {
     126              :       // Any out-of-range component (e.g. day 32, month 13) will cause
     127              :       // DateTime() to throw — catch all and return null.
     128              :       return null;
     129              :     }
     130              :   }
     131              : 
     132              :   /// Parses a decimal integer from [s] at character positions [start]..[end-1].
     133              :   ///
     134              :   /// Returns `null` if the substring contains non-digit characters.
     135            4 :   static int? _parseInt(String s, int start, int end) {
     136            8 :     if (end > s.length) end = s.length;
     137            4 :     if (start >= end) return null;
     138            4 :     final sub = s.substring(start, end);
     139            4 :     return int.tryParse(sub);
     140              :   }
     141              : 
     142              :   /// Parses an offset component (hours or minutes) from a substring.
     143              :   ///
     144              :   /// The offset string has the form `HH'mm'` or `HH:mm` or just `HH`. This
     145              :   /// method skips any non-digit separator character and reads 2 digits at the
     146              :   /// given [position] within the effective digit sequence.
     147              :   ///
     148              :   /// For [position] 0 → reads characters 0–1 (hours).
     149              :   /// For [position] 2 → reads characters after the separator (minutes).
     150            2 :   static int? _parseOffsetComponent(String s, int position) {
     151              :     // Build a digit-only version of the offset string by filtering separators.
     152              :     // e.g. "05'30'" → "0530", "05:30" → "0530", "0530" → "0530"
     153            4 :     final digits = s.replaceAll(RegExp(r"[^0-9]"), '');
     154            4 :     return _parseInt(digits, position, position + 2);
     155              :   }
     156              : }
        

Generated by: LCOV version 2.0-1