Skip to main content

folio_core/
date.rs

1//! PDF date parsing and formatting.
2//!
3//! PDF dates follow the format: D:YYYYMMDDHHmmSSOHH'mm'
4//! where O is the timezone offset direction (+, -, or Z).
5
6/// A parsed PDF date.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub struct PdfDate {
9    pub year: u16,
10    pub month: u8,
11    pub day: u8,
12    pub hour: u8,
13    pub minute: u8,
14    pub second: u8,
15    /// Timezone indicator: '+', '-', or 'Z'
16    pub ut: char,
17    /// Timezone offset hours
18    pub ut_hour: u8,
19    /// Timezone offset minutes
20    pub ut_minutes: u8,
21}
22
23impl PdfDate {
24    /// Parse a PDF date string.
25    ///
26    /// Accepts formats like:
27    /// - "D:20231015120000+05'30'"
28    /// - "D:20231015"
29    /// - "20231015120000Z"
30    pub fn parse(s: &str) -> Option<Self> {
31        let s = s.strip_prefix("D:").unwrap_or(s);
32        if s.len() < 4 {
33            return None;
34        }
35
36        let year: u16 = s.get(0..4)?.parse().ok()?;
37        let month: u8 = s.get(4..6).and_then(|v| v.parse().ok()).unwrap_or(1);
38        let day: u8 = s.get(6..8).and_then(|v| v.parse().ok()).unwrap_or(1);
39        let hour: u8 = s.get(8..10).and_then(|v| v.parse().ok()).unwrap_or(0);
40        let minute: u8 = s.get(10..12).and_then(|v| v.parse().ok()).unwrap_or(0);
41        let second: u8 = s.get(12..14).and_then(|v| v.parse().ok()).unwrap_or(0);
42
43        let rest = s.get(14..).unwrap_or("");
44        let (ut, ut_hour, ut_minutes) = if rest.is_empty() {
45            ('Z', 0, 0)
46        } else {
47            let first = rest.chars().next()?;
48            match first {
49                'Z' => ('Z', 0, 0),
50                '+' | '-' => {
51                    let tz = &rest[1..];
52                    let tz = tz.replace('\'', "");
53                    let uh: u8 = tz.get(0..2).and_then(|v| v.parse().ok()).unwrap_or(0);
54                    let um: u8 = tz.get(2..4).and_then(|v| v.parse().ok()).unwrap_or(0);
55                    (first, uh, um)
56                }
57                _ => ('Z', 0, 0),
58            }
59        };
60
61        Some(PdfDate {
62            year,
63            month,
64            day,
65            hour,
66            minute,
67            second,
68            ut,
69            ut_hour,
70            ut_minutes,
71        })
72    }
73
74    /// Format as a PDF date string.
75    pub fn to_pdf_string(&self) -> String {
76        if self.ut == 'Z' {
77            format!(
78                "D:{:04}{:02}{:02}{:02}{:02}{:02}Z",
79                self.year, self.month, self.day, self.hour, self.minute, self.second
80            )
81        } else {
82            format!(
83                "D:{:04}{:02}{:02}{:02}{:02}{:02}{}{:02}'{:02}'",
84                self.year,
85                self.month,
86                self.day,
87                self.hour,
88                self.minute,
89                self.second,
90                self.ut,
91                self.ut_hour,
92                self.ut_minutes
93            )
94        }
95    }
96}
97
98impl std::fmt::Display for PdfDate {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        write!(f, "{}", self.to_pdf_string())
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_parse_full() {
110        let d = PdfDate::parse("D:20231015120000+05'30'").unwrap();
111        assert_eq!(d.year, 2023);
112        assert_eq!(d.month, 10);
113        assert_eq!(d.day, 15);
114        assert_eq!(d.hour, 12);
115        assert_eq!(d.minute, 0);
116        assert_eq!(d.second, 0);
117        assert_eq!(d.ut, '+');
118        assert_eq!(d.ut_hour, 5);
119        assert_eq!(d.ut_minutes, 30);
120    }
121
122    #[test]
123    fn test_parse_minimal() {
124        let d = PdfDate::parse("D:2023").unwrap();
125        assert_eq!(d.year, 2023);
126        assert_eq!(d.month, 1);
127        assert_eq!(d.day, 1);
128    }
129
130    #[test]
131    fn test_parse_zulu() {
132        let d = PdfDate::parse("D:20231015120000Z").unwrap();
133        assert_eq!(d.ut, 'Z');
134        assert_eq!(d.ut_hour, 0);
135    }
136
137    #[test]
138    fn test_roundtrip() {
139        let original = "D:20231015120000+05'30'";
140        let d = PdfDate::parse(original).unwrap();
141        assert_eq!(d.to_pdf_string(), original);
142    }
143
144    #[test]
145    fn test_roundtrip_zulu() {
146        let original = "D:20231015120000Z";
147        let d = PdfDate::parse(original).unwrap();
148        assert_eq!(d.to_pdf_string(), original);
149    }
150}