1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct Date {
14 pub year: u16,
16 pub month: u8,
18 pub day: u8,
20 pub hour: u8,
22 pub minute: u8,
24 pub second: u8,
26 pub utc_offset_minutes: i16,
28}
29
30impl Date {
31 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 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 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
134fn 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
151fn 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
161fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}