#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Date {
pub year: u16,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub second: u8,
pub utc_offset_minutes: i16,
}
impl Date {
pub fn to_pdf_string(self) -> String {
let Date {
year,
month,
day,
hour,
minute,
second,
utc_offset_minutes,
} = self;
let mut out = format!("D:{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}");
if utc_offset_minutes == 0 {
out.push('Z');
return out;
}
let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
let magnitude = utc_offset_minutes.unsigned_abs();
out.push_str(&format!(
"{sign}{:02}'{:02}",
magnitude / 60,
magnitude % 60
));
out
}
pub fn to_iso8601(self) -> String {
let Date {
year,
month,
day,
hour,
minute,
second,
utc_offset_minutes,
} = self;
let mut out = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}");
if utc_offset_minutes == 0 {
out.push('Z');
return out;
}
let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
let magnitude = utc_offset_minutes.unsigned_abs();
out.push_str(&format!(
"{sign}{:02}:{:02}",
magnitude / 60,
magnitude % 60
));
out
}
pub fn parse_pdf(s: &str) -> Option<Date> {
let bytes = s.strip_prefix("D:").unwrap_or(s).as_bytes();
let mut pos = 0;
let year = take_required_digits(bytes, &mut pos, 4)? as u16;
let month = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(1);
let day = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(1);
let hour = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(0);
let minute = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(0);
let second = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(0);
let in_range = (1..=12).contains(&month)
&& (1..=31).contains(&day)
&& hour <= 23
&& minute <= 59
&& second <= 59;
if !in_range {
return None;
}
let utc_offset_minutes = parse_pdf_offset(bytes, pos)?;
Some(Date {
year,
month: month as u8,
day: day as u8,
hour: hour as u8,
minute: minute as u8,
second: second as u8,
utc_offset_minutes,
})
}
}
fn take_required_digits(bytes: &[u8], pos: &mut usize, width: usize) -> Option<u32> {
let end = *pos + width;
let digits = bytes.get(*pos..end)?;
if !digits.iter().all(|d| d.is_ascii_digit()) {
return None;
}
*pos = end;
Some(
digits
.iter()
.fold(0u32, |acc, &d| acc * 10 + u32::from(d - b'0')),
)
}
fn take_optional_digits(bytes: &[u8], pos: &mut usize, width: usize) -> Option<Option<u32>> {
if !bytes.get(*pos).is_some_and(u8::is_ascii_digit) {
return Some(None);
}
take_required_digits(bytes, pos, width).map(Some)
}
fn parse_pdf_offset(bytes: &[u8], pos: usize) -> Option<i16> {
if pos >= bytes.len() || bytes[pos] == b'Z' {
return Some(0);
}
let sign = match bytes[pos] {
b'+' => 1,
b'-' => -1,
_ => return None,
};
let mut cursor = pos + 1;
let hours = take_required_digits(bytes, &mut cursor, 2)?;
if bytes.get(cursor) == Some(&b'\'') {
cursor += 1;
}
let minutes = take_optional_digits(bytes, &mut cursor, 2)?.unwrap_or(0);
if hours > 23 || minutes > 59 {
return None;
}
let magnitude = i16::try_from(hours * 60 + minutes).ok()?;
Some(sign * magnitude)
}
#[cfg(test)]
mod tests {
use super::*;
fn date(
year: u16,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
utc_offset_minutes: i16,
) -> Date {
Date {
year,
month,
day,
hour,
minute,
second,
utc_offset_minutes,
}
}
#[test]
fn date_utc_formats_with_z() {
assert_eq!(
date(2026, 8, 27, 12, 30, 15, 0).to_pdf_string(),
"D:20260827123015Z"
);
}
#[test]
fn date_positive_offset_pads_single_digits() {
assert_eq!(
date(987, 1, 2, 3, 4, 5, 120).to_pdf_string(),
"D:09870102030405+02'00"
);
}
#[test]
fn date_negative_offset_keeps_minutes() {
assert_eq!(
date(1999, 12, 31, 23, 59, 58, -330).to_pdf_string(),
"D:19991231235958-05'30"
);
}
#[test]
fn iso8601_utc_formats_with_z() {
assert_eq!(
date(2026, 8, 27, 12, 30, 15, 0).to_iso8601(),
"2026-08-27T12:30:15Z"
);
}
#[test]
fn iso8601_positive_offset_pads_single_digits() {
assert_eq!(
date(987, 1, 2, 3, 4, 5, 120).to_iso8601(),
"0987-01-02T03:04:05+02:00"
);
}
#[test]
fn iso8601_negative_offset_keeps_minutes() {
assert_eq!(
date(1999, 12, 31, 23, 59, 58, -330).to_iso8601(),
"1999-12-31T23:59:58-05:30"
);
}
#[test]
fn parse_pdf_the_clause_example() {
assert_eq!(
Date::parse_pdf("D:199812231952-08'00"),
Some(date(1998, 12, 23, 19, 52, 0, -480))
);
}
#[test]
fn parse_pdf_partial_fields_take_their_defaults() {
assert_eq!(
Date::parse_pdf("D:20260901"),
Some(date(2026, 9, 1, 0, 0, 0, 0))
);
assert_eq!(
Date::parse_pdf("D:2026"),
Some(date(2026, 1, 1, 0, 0, 0, 0))
);
assert_eq!(
Date::parse_pdf("D:20050321140532-05"),
Some(date(2005, 3, 21, 14, 5, 32, -300))
);
}
#[test]
fn parse_pdf_accepts_the_common_deviations() {
assert_eq!(
Date::parse_pdf("D:20030326135148+01'00'"),
Some(date(2003, 3, 26, 13, 51, 48, 60))
);
assert_eq!(
Date::parse_pdf("D:20050407135940Z00'00'"),
Some(date(2005, 4, 7, 13, 59, 40, 0))
);
assert_eq!(
Date::parse_pdf("D:20100316021332-0130"),
Some(date(2010, 3, 16, 2, 13, 32, -90))
);
assert_eq!(
Date::parse_pdf("20260901120000Z"),
Some(date(2026, 9, 1, 12, 0, 0, 0))
);
}
#[test]
fn parse_pdf_refuses_out_of_range_fields() {
assert_eq!(Date::parse_pdf("D:191020717014604"), None, "month 20");
assert_eq!(Date::parse_pdf("D:20261301"), None, "month 13");
assert_eq!(Date::parse_pdf("D:20260900"), None, "day 0");
assert_eq!(Date::parse_pdf("D:2026090124"), None, "hour 24");
assert_eq!(Date::parse_pdf("D:202609011260"), None, "minute 60");
assert_eq!(
Date::parse_pdf("D:20260901120000+24'00"),
None,
"offset hour 24"
);
assert_eq!(
Date::parse_pdf("D:20260901120000+02'60"),
None,
"offset minute 60"
);
}
#[test]
fn parse_pdf_rejects_garbage() {
assert!(Date::parse_pdf("yesterday").is_none());
assert!(Date::parse_pdf("02/04/1998 01:11:16 PM").is_none());
assert!(Date::parse_pdf("D:2026090112000X").is_none());
assert!(Date::parse_pdf("").is_none());
}
#[test]
fn parse_roundtrips_to_pdf_string() {
let date = date(2026, 8, 27, 12, 30, 15, -330);
assert_eq!(Date::parse_pdf(&date.to_pdf_string()), Some(date));
}
}