use crate::error::{Error, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PdfStandard {
V1_4,
V1_5,
V1_6,
#[default]
V1_7,
V2_0,
A1b,
A1a,
A2b,
A2u,
A2a,
A3b,
A3u,
A3a,
A4,
A4f,
A4e,
Ua1,
}
impl PdfStandard {
fn to_typst(self) -> typst_pdf::PdfStandard {
use typst_pdf::PdfStandard as T;
match self {
PdfStandard::V1_4 => T::V_1_4,
PdfStandard::V1_5 => T::V_1_5,
PdfStandard::V1_6 => T::V_1_6,
PdfStandard::V1_7 => T::V_1_7,
PdfStandard::V2_0 => T::V_2_0,
PdfStandard::A1b => T::A_1b,
PdfStandard::A1a => T::A_1a,
PdfStandard::A2b => T::A_2b,
PdfStandard::A2u => T::A_2u,
PdfStandard::A2a => T::A_2a,
PdfStandard::A3b => T::A_3b,
PdfStandard::A3u => T::A_3u,
PdfStandard::A3a => T::A_3a,
PdfStandard::A4 => T::A_4,
PdfStandard::A4f => T::A_4f,
PdfStandard::A4e => T::A_4e,
PdfStandard::Ua1 => T::Ua_1,
}
}
pub(crate) fn requires_tagging(self) -> bool {
matches!(
self,
PdfStandard::A1a | PdfStandard::A2a | PdfStandard::A3a | PdfStandard::Ua1
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PdfTimestamp {
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
offset_minutes: Option<i32>,
}
fn valid_offset(minutes: i32) -> bool {
(-(23 * 60 + 59)..=(23 * 60 + 59)).contains(&minutes)
}
impl PdfTimestamp {
pub fn now_utc() -> Self {
let (year, month, day, hour, minute, second) = civil_from_unix(now_unix_secs());
Self {
year,
month,
day,
hour,
minute,
second,
offset_minutes: None,
}
}
pub fn now_local(offset_minutes: i32) -> Option<Self> {
if !valid_offset(offset_minutes) {
return None;
}
let (year, month, day, hour, minute, second) =
civil_from_unix(now_unix_secs() + offset_minutes as i64 * 60);
Some(Self {
year,
month,
day,
hour,
minute,
second,
offset_minutes: Some(offset_minutes),
})
}
pub fn utc(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> Option<Self> {
typst::foundations::Datetime::from_ymd_hms(year, month, day, hour, minute, second)?;
Some(Self {
year,
month,
day,
hour,
minute,
second,
offset_minutes: None,
})
}
pub fn local(
year: i32,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
offset_minutes: i32,
) -> Option<Self> {
typst::foundations::Datetime::from_ymd_hms(year, month, day, hour, minute, second)?;
if !valid_offset(offset_minutes) {
return None;
}
Some(Self {
year,
month,
day,
hour,
minute,
second,
offset_minutes: Some(offset_minutes),
})
}
fn to_typst(self) -> Option<typst_pdf::Timestamp> {
let datetime = typst::foundations::Datetime::from_ymd_hms(
self.year,
self.month,
self.day,
self.hour,
self.minute,
self.second,
)?;
match self.offset_minutes {
None => Some(typst_pdf::Timestamp::new_utc(datetime)),
Some(offset) => typst_pdf::Timestamp::new_local(datetime, offset),
}
}
}
fn now_unix_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn civil_from_unix(secs: i64) -> (i32, u8, u8, u8, u8, u8) {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let hour = (rem / 3_600) as u8;
let minute = ((rem % 3_600) / 60) as u8;
let second = (rem % 60) as u8;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let day = (doy - (153 * mp + 2) / 5 + 1) as u8; let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u8; let year = (y + if month <= 2 { 1 } else { 0 }) as i32;
(year, month, day, hour, minute, second)
}
#[derive(Clone, Debug)]
pub struct PdfConfig {
pub standard: PdfStandard,
pub tagged: bool,
pub ident: Option<String>,
pub timestamp: Option<PdfTimestamp>,
}
impl Default for PdfConfig {
fn default() -> Self {
Self {
standard: PdfStandard::default(),
tagged: true,
ident: None,
timestamp: None,
}
}
}
impl PdfConfig {
pub(crate) fn to_typst(&self) -> Result<typst_pdf::PdfOptions<'_>> {
use typst::foundations::Smart;
if self.standard.requires_tagging() && !self.tagged {
return Err(Error::InvalidPdfConfig(format!(
"{:?} requires tagged PDF; remove `tagged: false`",
self.standard
)));
}
if matches!(&self.ident, Some(s) if s.is_empty()) {
return Err(Error::InvalidPdfConfig(
"ident must not be empty; use None for an automatic identifier".into(),
));
}
let standards = typst_pdf::PdfStandards::new(&[self.standard.to_typst()])
.map_err(|e| Error::InvalidPdfConfig(e.to_string()))?;
let timestamp = match self.timestamp {
Some(ts) => Some(
ts.to_typst()
.ok_or_else(|| Error::InvalidPdfConfig("invalid timestamp".into()))?,
),
None => None,
};
Ok(typst_pdf::PdfOptions {
ident: self
.ident
.as_deref()
.map(Smart::Custom)
.unwrap_or(Smart::Auto),
timestamp,
page_ranges: None,
standards,
tagged: self.tagged,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_matches_typst_defaults() {
let cfg = PdfConfig::default();
let opts = cfg.to_typst().unwrap();
assert!(opts.tagged);
assert!(matches!(opts.ident, typst::foundations::Smart::Auto));
assert!(opts.timestamp.is_none());
assert!(opts.page_ranges.is_none());
}
#[test]
fn tagged_false_is_ok_for_basic_standards() {
let cfg = PdfConfig {
tagged: false,
standard: PdfStandard::A2b,
..Default::default()
};
assert!(cfg.to_typst().is_ok());
}
#[test]
fn accessible_standard_rejects_untagged() {
for standard in [
PdfStandard::A1a,
PdfStandard::A2a,
PdfStandard::A3a,
PdfStandard::Ua1,
] {
let cfg = PdfConfig {
tagged: false,
standard,
..Default::default()
};
assert!(matches!(cfg.to_typst(), Err(Error::InvalidPdfConfig(_))));
}
}
#[test]
fn empty_ident_is_rejected() {
let cfg = PdfConfig {
ident: Some(String::new()),
..Default::default()
};
assert!(matches!(cfg.to_typst(), Err(Error::InvalidPdfConfig(_))));
}
#[test]
fn representative_standards_convert() {
for standard in [PdfStandard::A2b, PdfStandard::V2_0, PdfStandard::A4] {
let cfg = PdfConfig {
standard,
..Default::default()
};
assert!(cfg.to_typst().is_ok());
}
}
#[test]
fn timestamp_constructors() {
assert!(PdfTimestamp::utc(2026, 6, 6, 12, 0, 0).is_some());
assert!(PdfTimestamp::utc(2026, 13, 1, 0, 0, 0).is_none());
assert!(PdfTimestamp::local(2026, 6, 6, 12, 0, 0, 540).is_some());
assert!(PdfTimestamp::now_local(99 * 60).is_none());
assert!(PdfTimestamp::now_utc().to_typst().is_some());
}
#[test]
fn now_local_offset_shifts_wall_clock() {
let utc = PdfTimestamp::now_utc();
let local = PdfTimestamp::now_local(60).unwrap();
let utc_minutes = utc.hour as i32 * 60 + utc.minute as i32;
let local_minutes = local.hour as i32 * 60 + local.minute as i32;
let diff = (local_minutes - utc_minutes).rem_euclid(24 * 60);
assert!(diff == 60 || diff == 59 || diff == 61, "diff was {diff}");
}
#[test]
fn civil_from_unix_known_values() {
assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0));
assert_eq!(civil_from_unix(1_780_747_200), (2026, 6, 6, 12, 0, 0));
assert_eq!(civil_from_unix(951_782_400), (2000, 2, 29, 0, 0, 0));
assert_eq!(civil_from_unix(4_107_542_400), (2100, 3, 1, 0, 0, 0));
assert_eq!(civil_from_unix(-1), (1969, 12, 31, 23, 59, 59));
}
}