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 : }
|