Skip to main content

pdfboss_core/
date.rs

1//! Date strings (ISO 32000-1 §7.9.4): `D:YYYYMMDDHHmmSSOHH'mm'`, read
2//! leniently into [`Date`] and written back, or formatted as ISO 8601 for
3//! XMP and display.
4
5/// A calendar date and time with a UTC offset: the value of `/CreationDate`
6/// and `/ModDate` in the document information dictionary, and of every
7/// other date string in a file. The writer never reads a clock; dates
8/// appear in output only when a caller provides them, keeping builds
9/// reproducible.
10///
11/// Covers ISO 32000-1 §7.9.4.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct Date {
14    /// Four-digit year.
15    pub year: u16,
16    /// Month, 1–12.
17    pub month: u8,
18    /// Day of month, 1–31.
19    pub day: u8,
20    /// Hour, 0–23.
21    pub hour: u8,
22    /// Minute, 0–59.
23    pub minute: u8,
24    /// Second, 0–59.
25    pub second: u8,
26    /// Offset from UTC in minutes (positive east).
27    pub utc_offset_minutes: i16,
28}
29
30impl Date {
31    /// Formats as a PDF date string, `D:YYYYMMDDHHmmSSOHH'mm` — with a
32    /// literal `Z` in place of the offset when the date is exactly UTC.
33    ///
34    /// Covers ISO 32000-1 §7.9.4.
35    pub fn to_pdf_string(self) -> String {
36        let Date {
37            year,
38            month,
39            day,
40            hour,
41            minute,
42            second,
43            utc_offset_minutes,
44        } = self;
45        let mut out = format!("D:{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}");
46        if utc_offset_minutes == 0 {
47            out.push('Z');
48            return out;
49        }
50        let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
51        let magnitude = utc_offset_minutes.unsigned_abs();
52        out.push_str(&format!(
53            "{sign}{:02}'{:02}",
54            magnitude / 60,
55            magnitude % 60
56        ));
57        out
58    }
59
60    /// Formats as an ISO-8601 date-time, `YYYY-MM-DDTHH:mm:SS±HH:MM` — with
61    /// a literal `Z` in place of the offset when the date is exactly UTC.
62    /// The form of the XMP `xmp:CreateDate`/`xmp:ModifyDate` elements and
63    /// of the CLI's `info` output.
64    ///
65    /// Covers ISO 32000-1 §7.9.4.
66    pub fn to_iso8601(self) -> String {
67        let Date {
68            year,
69            month,
70            day,
71            hour,
72            minute,
73            second,
74            utc_offset_minutes,
75        } = self;
76        let mut out = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}");
77        if utc_offset_minutes == 0 {
78            out.push('Z');
79            return out;
80        }
81        let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
82        let magnitude = utc_offset_minutes.unsigned_abs();
83        out.push_str(&format!(
84            "{sign}{:02}:{:02}",
85            magnitude / 60,
86            magnitude % 60
87        ));
88        out
89    }
90
91    /// Parses a PDF date string (ISO 32000 §7.9.4): the `D:` prefix (taken
92    /// as optional, since producers omit it), a required 4-digit year, then
93    /// optional 2-digit month, day, hour, minute and second, each present
94    /// only if the one before it is (month and day default to 1, the rest
95    /// to 0), then an optional UTC offset: `Z`, or `+`/`-HH` with optional
96    /// `'mm`. Producers' deviations are taken too: a trailing apostrophe
97    /// after the offset minutes, `Z` followed by offset digits, and offset
98    /// minutes without the apostrophe. `None` when the year is missing, a
99    /// present field is not digits or outside its range (month 1–12, day
100    /// 1–31, hour 0–23, minute and second 0–59, offset hour 0–23 and
101    /// minute 0–59), or the offset marker is neither `Z` nor a sign.
102    ///
103    /// Covers ISO 32000-1 §7.9.4.
104    pub fn parse_pdf(s: &str) -> Option<Date> {
105        let bytes = s.strip_prefix("D:").unwrap_or(s).as_bytes();
106        let mut pos = 0;
107        let year = take_required_digits(bytes, &mut pos, 4)? as u16;
108        let month = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(1);
109        let day = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(1);
110        let hour = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(0);
111        let minute = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(0);
112        let second = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(0);
113        let in_range = (1..=12).contains(&month)
114            && (1..=31).contains(&day)
115            && hour <= 23
116            && minute <= 59
117            && second <= 59;
118        if !in_range {
119            return None;
120        }
121        let utc_offset_minutes = parse_pdf_offset(bytes, pos)?;
122        Some(Date {
123            year,
124            month: month as u8,
125            day: day as u8,
126            hour: hour as u8,
127            minute: minute as u8,
128            second: second as u8,
129            utc_offset_minutes,
130        })
131    }
132}
133
134/// Reads exactly `width` ASCII digits at `bytes[*pos..]` as a decimal
135/// value, advancing `pos` past them. `None` when fewer than `width` bytes
136/// remain or one of them is not a digit.
137fn take_required_digits(bytes: &[u8], pos: &mut usize, width: usize) -> Option<u32> {
138    let end = *pos + width;
139    let digits = bytes.get(*pos..end)?;
140    if !digits.iter().all(|d| d.is_ascii_digit()) {
141        return None;
142    }
143    *pos = end;
144    Some(
145        digits
146            .iter()
147            .fold(0u32, |acc, &d| acc * 10 + u32::from(d - b'0')),
148    )
149}
150
151/// [`take_required_digits`], but a field that does not start with a digit
152/// (nothing left to read, or the offset marker next) is absent
153/// (`Some(None)`) rather than malformed.
154fn take_optional_digits(bytes: &[u8], pos: &mut usize, width: usize) -> Option<Option<u32>> {
155    if !bytes.get(*pos).is_some_and(u8::is_ascii_digit) {
156        return Some(None);
157    }
158    take_required_digits(bytes, pos, width).map(Some)
159}
160
161/// Parses the UTC offset trailing a PDF date string's digits, in minutes:
162/// zero when nothing is left to read, zero for `Z` (whatever follows it),
163/// or the signed magnitude of `+`/`-HH'mm`, the `'mm` half optional and
164/// also taken without its apostrophe. `None` when what remains is neither,
165/// or the hours or minutes are out of range.
166fn parse_pdf_offset(bytes: &[u8], pos: usize) -> Option<i16> {
167    if pos >= bytes.len() || bytes[pos] == b'Z' {
168        return Some(0);
169    }
170    let sign = match bytes[pos] {
171        b'+' => 1,
172        b'-' => -1,
173        _ => return None,
174    };
175    let mut cursor = pos + 1;
176    let hours = take_required_digits(bytes, &mut cursor, 2)?;
177    if bytes.get(cursor) == Some(&b'\'') {
178        cursor += 1;
179    }
180    let minutes = take_optional_digits(bytes, &mut cursor, 2)?.unwrap_or(0);
181    if hours > 23 || minutes > 59 {
182        return None;
183    }
184    let magnitude = i16::try_from(hours * 60 + minutes).ok()?;
185    Some(sign * magnitude)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn date(
193        year: u16,
194        month: u8,
195        day: u8,
196        hour: u8,
197        minute: u8,
198        second: u8,
199        utc_offset_minutes: i16,
200    ) -> Date {
201        Date {
202            year,
203            month,
204            day,
205            hour,
206            minute,
207            second,
208            utc_offset_minutes,
209        }
210    }
211
212    // Covers ISO 32000-1 §7.9.4.
213    #[test]
214    fn date_utc_formats_with_z() {
215        assert_eq!(
216            date(2026, 8, 27, 12, 30, 15, 0).to_pdf_string(),
217            "D:20260827123015Z"
218        );
219    }
220
221    // Covers ISO 32000-1 §7.9.4.
222    #[test]
223    fn date_positive_offset_pads_single_digits() {
224        assert_eq!(
225            date(987, 1, 2, 3, 4, 5, 120).to_pdf_string(),
226            "D:09870102030405+02'00"
227        );
228    }
229
230    // Covers ISO 32000-1 §7.9.4.
231    #[test]
232    fn date_negative_offset_keeps_minutes() {
233        assert_eq!(
234            date(1999, 12, 31, 23, 59, 58, -330).to_pdf_string(),
235            "D:19991231235958-05'30"
236        );
237    }
238
239    #[test]
240    fn iso8601_utc_formats_with_z() {
241        assert_eq!(
242            date(2026, 8, 27, 12, 30, 15, 0).to_iso8601(),
243            "2026-08-27T12:30:15Z"
244        );
245    }
246
247    #[test]
248    fn iso8601_positive_offset_pads_single_digits() {
249        assert_eq!(
250            date(987, 1, 2, 3, 4, 5, 120).to_iso8601(),
251            "0987-01-02T03:04:05+02:00"
252        );
253    }
254
255    #[test]
256    fn iso8601_negative_offset_keeps_minutes() {
257        assert_eq!(
258            date(1999, 12, 31, 23, 59, 58, -330).to_iso8601(),
259            "1999-12-31T23:59:58-05:30"
260        );
261    }
262
263    // Covers ISO 32000-1 §7.9.4: the clause's own example.
264    #[test]
265    fn parse_pdf_the_clause_example() {
266        assert_eq!(
267            Date::parse_pdf("D:199812231952-08'00"),
268            Some(date(1998, 12, 23, 19, 52, 0, -480))
269        );
270    }
271
272    // Covers ISO 32000-1 §7.9.4: every field after the year is optional and
273    // takes its default.
274    #[test]
275    fn parse_pdf_partial_fields_take_their_defaults() {
276        assert_eq!(
277            Date::parse_pdf("D:20260901"),
278            Some(date(2026, 9, 1, 0, 0, 0, 0))
279        );
280        assert_eq!(
281            Date::parse_pdf("D:2026"),
282            Some(date(2026, 1, 1, 0, 0, 0, 0))
283        );
284        assert_eq!(
285            Date::parse_pdf("D:20050321140532-05"),
286            Some(date(2005, 3, 21, 14, 5, 32, -300))
287        );
288    }
289
290    // Covers ISO 32000-1 §7.9.4: the forms producers actually write around
291    // the grammar, a trailing apostrophe after the offset minutes, `Z`
292    // followed by zero offset digits, an offset without apostrophes, and no
293    // `D:` prefix.
294    #[test]
295    fn parse_pdf_accepts_the_common_deviations() {
296        assert_eq!(
297            Date::parse_pdf("D:20030326135148+01'00'"),
298            Some(date(2003, 3, 26, 13, 51, 48, 60))
299        );
300        assert_eq!(
301            Date::parse_pdf("D:20050407135940Z00'00'"),
302            Some(date(2005, 4, 7, 13, 59, 40, 0))
303        );
304        assert_eq!(
305            Date::parse_pdf("D:20100316021332-0130"),
306            Some(date(2010, 3, 16, 2, 13, 32, -90))
307        );
308        assert_eq!(
309            Date::parse_pdf("20260901120000Z"),
310            Some(date(2026, 9, 1, 12, 0, 0, 0))
311        );
312    }
313
314    // Covers ISO 32000-1 §7.9.4: a field outside its range is not a date.
315    #[test]
316    fn parse_pdf_refuses_out_of_range_fields() {
317        assert_eq!(Date::parse_pdf("D:191020717014604"), None, "month 20");
318        assert_eq!(Date::parse_pdf("D:20261301"), None, "month 13");
319        assert_eq!(Date::parse_pdf("D:20260900"), None, "day 0");
320        assert_eq!(Date::parse_pdf("D:2026090124"), None, "hour 24");
321        assert_eq!(Date::parse_pdf("D:202609011260"), None, "minute 60");
322        assert_eq!(
323            Date::parse_pdf("D:20260901120000+24'00"),
324            None,
325            "offset hour 24"
326        );
327        assert_eq!(
328            Date::parse_pdf("D:20260901120000+02'60"),
329            None,
330            "offset minute 60"
331        );
332    }
333
334    #[test]
335    fn parse_pdf_rejects_garbage() {
336        assert!(Date::parse_pdf("yesterday").is_none());
337        assert!(Date::parse_pdf("02/04/1998 01:11:16 PM").is_none());
338        assert!(Date::parse_pdf("D:2026090112000X").is_none());
339        assert!(Date::parse_pdf("").is_none());
340    }
341
342    #[test]
343    fn parse_roundtrips_to_pdf_string() {
344        let date = date(2026, 8, 27, 12, 30, 15, -330);
345        assert_eq!(Date::parse_pdf(&date.to_pdf_string()), Some(date));
346    }
347}