Skip to main content

pdf_syntax/object/
date.rs

1use crate::byte_reader::Reader;
2use core::str::FromStr;
3
4/// A date time.
5#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
6pub struct DateTime {
7    /// The year.
8    pub year: u16,
9    /// The year.
10    pub month: u8,
11    /// The day.
12    pub day: u8,
13    /// The hour.
14    pub hour: u8,
15    /// The minute.
16    pub minute: u8,
17    /// The second.
18    pub second: u8,
19    /// The offset in hours from UTC.
20    pub utc_offset_hour: i8,
21    /// The offset in minutes from UTC.
22    pub utc_offset_minute: u8,
23    /// Whether the date string contained an explicit timezone indicator (Z, +, or -).
24    pub has_timezone: bool,
25}
26
27impl DateTime {
28    pub(crate) fn from_bytes(bytes: &[u8]) -> Option<Self> {
29        let mut reader = Reader::new(bytes);
30
31        reader.forward_tag(b"D:")?;
32
33        let read_num = |reader: &mut Reader<'_>, bytes: u8, min: u16, max: u16| -> Option<u16> {
34            if matches!(reader.peek_byte()?, b'-' | b'+' | b'Z') {
35                return None;
36            }
37
38            let num = u16::from_str(core::str::from_utf8(reader.read_bytes(bytes as usize)?).ok()?)
39                .ok()?;
40
41            if num < min || num > max {
42                return None;
43            }
44
45            Some(num)
46        };
47
48        let year = read_num(&mut reader, 4, 0, 9999)?;
49        let month = read_num(&mut reader, 2, 1, 12)
50            .map(|n| n as u8)
51            .unwrap_or(1);
52        let day = read_num(&mut reader, 2, 1, 31)
53            .map(|n| n as u8)
54            .unwrap_or(1);
55        let hour = read_num(&mut reader, 2, 0, 23)
56            .map(|n| n as u8)
57            .unwrap_or(0);
58        let minute = read_num(&mut reader, 2, 0, 59)
59            .map(|n| n as u8)
60            .unwrap_or(0);
61        let second = read_num(&mut reader, 2, 0, 59)
62            .map(|n| n as u8)
63            .unwrap_or(0);
64
65        let (utc_offset_hour, utc_offset_minute, has_timezone) = if !reader.at_end() {
66            let multiplier = match reader.read_byte()? {
67                b'-' => -1,
68                _ => 1, // covers both '+' and 'Z'
69            };
70
71            let hour = multiplier
72                * read_num(&mut reader, 2, 0, 23)
73                    .map(|n| n as i8)
74                    .unwrap_or(0);
75            reader.forward_tag(b"\'");
76            let minute = read_num(&mut reader, 2, 0, 59)
77                .map(|n| n as u8)
78                .unwrap_or(0);
79
80            (hour, minute, true)
81        } else {
82            (0, 0, false)
83        };
84
85        Some(Self {
86            year,
87            month,
88            day,
89            hour,
90            minute,
91            second,
92            utc_offset_hour,
93            utc_offset_minute,
94            has_timezone,
95        })
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::DateTime;
102
103    #[allow(clippy::too_many_arguments)]
104    fn dt(
105        year: u16,
106        month: u8,
107        day: u8,
108        hour: u8,
109        minute: u8,
110        second: u8,
111        utc_hour: i8,
112        utc_minute: u8,
113    ) -> DateTime {
114        DateTime {
115            year,
116            month,
117            day,
118            hour,
119            minute,
120            second,
121            utc_offset_hour: utc_hour,
122            utc_offset_minute: utc_minute,
123            has_timezone: false,
124        }
125    }
126
127    /// Like dt() but with explicit timezone.
128    #[allow(clippy::too_many_arguments)]
129    fn dt_tz(
130        year: u16,
131        month: u8,
132        day: u8,
133        hour: u8,
134        minute: u8,
135        second: u8,
136        utc_hour: i8,
137        utc_minute: u8,
138    ) -> DateTime {
139        DateTime {
140            year,
141            month,
142            day,
143            hour,
144            minute,
145            second,
146            utc_offset_hour: utc_hour,
147            utc_offset_minute: utc_minute,
148            has_timezone: true,
149        }
150    }
151
152    fn parse(str: &str) -> DateTime {
153        DateTime::from_bytes(str.as_bytes()).unwrap()
154    }
155
156    #[test]
157    fn year_only_defaults() {
158        assert_eq!(parse("D:2023"), dt(2023, 1, 1, 0, 0, 0, 0, 0));
159    }
160
161    #[test]
162    fn year_month_defaults() {
163        assert_eq!(parse("D:202312"), dt(2023, 12, 1, 0, 0, 0, 0, 0));
164    }
165
166    #[test]
167    fn year_month_day_defaults() {
168        assert_eq!(parse("D:20231225"), dt(2023, 12, 25, 0, 0, 0, 0, 0));
169    }
170
171    #[test]
172    fn ymdh() {
173        assert_eq!(parse("D:2023122514"), dt(2023, 12, 25, 14, 0, 0, 0, 0));
174    }
175
176    #[test]
177    fn ymdhm() {
178        assert_eq!(parse("D:202312251430"), dt(2023, 12, 25, 14, 30, 0, 0, 0));
179    }
180
181    #[test]
182    fn full_local_time() {
183        assert_eq!(
184            parse("D:20231225143015"),
185            dt(2023, 12, 25, 14, 30, 15, 0, 0)
186        );
187    }
188
189    #[test]
190    fn example_from_spec() {
191        assert_eq!(
192            parse("D:199812231952-08'00"),
193            dt_tz(1998, 12, 23, 19, 52, 0, -8, 0)
194        );
195    }
196
197    #[test]
198    fn positive_offset_with_minutes() {
199        assert_eq!(
200            parse("D:20230701120000+05'30"),
201            dt_tz(2023, 7, 1, 12, 0, 0, 5, 30)
202        );
203    }
204
205    #[test]
206    fn utc_z() {
207        assert_eq!(
208            parse("D:20230701120000Z"),
209            dt_tz(2023, 7, 1, 12, 0, 0, 0, 0)
210        );
211    }
212
213    #[test]
214    fn utc_z_with_zero_offsets() {
215        assert_eq!(
216            parse("D:20230701120000Z00'00"),
217            dt_tz(2023, 7, 1, 12, 0, 0, 0, 0)
218        );
219    }
220
221    #[test]
222    fn negative_offset_with_minutes() {
223        assert_eq!(
224            parse("D:20230701120000-03'15"),
225            dt_tz(2023, 7, 1, 12, 0, 0, -3, 15)
226        );
227    }
228
229    #[test]
230    fn leap_year() {
231        assert_eq!(
232            parse("D:20000229010203+01'00"),
233            dt_tz(2000, 2, 29, 1, 2, 3, 1, 0)
234        );
235    }
236
237    #[test]
238    fn max_values() {
239        assert_eq!(
240            parse("D:99991231235959+14'00"),
241            dt_tz(9999, 12, 31, 23, 59, 59, 14, 0)
242        );
243    }
244
245    #[test]
246    fn min_values() {
247        assert_eq!(
248            parse("D:00000101000000+00'00"),
249            dt_tz(0, 1, 1, 0, 0, 0, 0, 0)
250        );
251    }
252
253    #[test]
254    fn offset_hour_only() {
255        assert_eq!(
256            parse("D:202307011200+02"),
257            dt_tz(2023, 7, 1, 12, 0, 0, 2, 0)
258        );
259    }
260
261    #[test]
262    fn offset_negative_zero_hour() {
263        assert_eq!(
264            parse("D:202307011200-00'45"),
265            dt_tz(2023, 7, 1, 12, 0, 0, 0, 45)
266        );
267    }
268}