Skip to main content

typst_bake/
pdf_config.rs

1//! Builder-phase PDF export options.
2//!
3//! [`PdfConfig`] is passed to [`Document::with_pdf_config`](crate::Document::with_pdf_config)
4//! to control PDF-only export settings (tagging, conformance standard, document
5//! identifier, creation timestamp). These options affect the PDF export stage only;
6//! SVG/PNG output ignores them.
7//!
8//! All typst-pdf coupling is isolated to the private conversion functions in this
9//! module, so a typst version bump only needs to be checked here.
10
11use crate::error::{Error, Result};
12
13/// A PDF conformance standard to enforce on export.
14///
15/// Each variant maps 1:1 to a single typst PDF standard. typst 0.14 enforces at most
16/// one substandard at a time, so PDF/A and PDF/UA cannot be combined.
17///
18/// The accessible PDF/A levels (`A1a`, `A2a`, `A3a`) and `Ua1` require a tagged PDF;
19/// combining them with `tagged: false` or with page selection returns
20/// [`Error::InvalidPdfConfig`]. The basic/unicode levels and `A4*` do not require tagging.
21///
22/// Note: any PDF/A standard requires a document date. Provide one via
23/// [`PdfConfig::timestamp`] or `#set document(date: ..)` in the template; otherwise
24/// export fails with a "missing document date" error.
25//
26// Note: this enum is intentionally NOT `#[non_exhaustive]` so callers can match it
27// without a wildcard arm. The trade-off is that adding a variant later (if typst gains
28// a new standard) is a breaking change, handled by the version policy at that time.
29#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
30pub enum PdfStandard {
31    /// PDF 1.4.
32    V1_4,
33    /// PDF 1.5.
34    V1_5,
35    /// PDF 1.6.
36    V1_6,
37    /// PDF 1.7. This is the default.
38    #[default]
39    V1_7,
40    /// PDF 2.0.
41    V2_0,
42    /// PDF/A-1b (basic conformance).
43    A1b,
44    /// PDF/A-1a (accessible conformance; requires tagging).
45    A1a,
46    /// PDF/A-2b (basic conformance).
47    A2b,
48    /// PDF/A-2u (unicode conformance).
49    A2u,
50    /// PDF/A-2a (accessible conformance; requires tagging).
51    A2a,
52    /// PDF/A-3b (basic conformance).
53    A3b,
54    /// PDF/A-3u (unicode conformance).
55    A3u,
56    /// PDF/A-3a (accessible conformance; requires tagging).
57    A3a,
58    /// PDF/A-4.
59    A4,
60    /// PDF/A-4f.
61    A4f,
62    /// PDF/A-4e.
63    A4e,
64    /// PDF/UA-1 (accessibility; requires tagging).
65    Ua1,
66}
67
68impl PdfStandard {
69    // `typst_pdf::PdfStandard` is `#[non_exhaustive]` at the enum level, but constructing
70    // its existing variants from outside the crate is allowed (only exhaustive matching is
71    // restricted). So this 1:1 construction mapping compiles.
72    fn to_typst(self) -> typst_pdf::PdfStandard {
73        use typst_pdf::PdfStandard as T;
74        match self {
75            PdfStandard::V1_4 => T::V_1_4,
76            PdfStandard::V1_5 => T::V_1_5,
77            PdfStandard::V1_6 => T::V_1_6,
78            PdfStandard::V1_7 => T::V_1_7,
79            PdfStandard::V2_0 => T::V_2_0,
80            PdfStandard::A1b => T::A_1b,
81            PdfStandard::A1a => T::A_1a,
82            PdfStandard::A2b => T::A_2b,
83            PdfStandard::A2u => T::A_2u,
84            PdfStandard::A2a => T::A_2a,
85            PdfStandard::A3b => T::A_3b,
86            PdfStandard::A3u => T::A_3u,
87            PdfStandard::A3a => T::A_3a,
88            PdfStandard::A4 => T::A_4,
89            PdfStandard::A4f => T::A_4f,
90            PdfStandard::A4e => T::A_4e,
91            PdfStandard::Ua1 => T::Ua_1,
92        }
93    }
94
95    /// Whether this standard mandates a tagged PDF (structure tree).
96    ///
97    /// Mirrors krilla's `Validator::requires_tagging`: the accessible PDF/A levels
98    /// (`A1a`, `A2a`, `A3a`) and `Ua1` require tagging.
99    pub(crate) fn requires_tagging(self) -> bool {
100        matches!(
101            self,
102            PdfStandard::A1a | PdfStandard::A2a | PdfStandard::A3a | PdfStandard::Ua1
103        )
104    }
105}
106
107/// A PDF creation timestamp.
108///
109/// Stores plain calendar fields (no external date dependency). The value is applied
110/// only when the template's document date is `auto`; a `#set document(date: ..)` in the
111/// template takes precedence.
112///
113/// For the common case of "now in UTC", use [`PdfTimestamp::now_utc`]. To attach a
114/// timezone offset, supply it explicitly with [`PdfTimestamp::now_local`] or
115/// [`PdfTimestamp::local`] — local timezone auto-detection is not supported.
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub struct PdfTimestamp {
118    year: i32,
119    month: u8,
120    day: u8,
121    hour: u8,
122    minute: u8,
123    second: u8,
124    /// Minutes offset from UTC; `None` means UTC.
125    offset_minutes: Option<i32>,
126}
127
128/// Valid whole-minute UTC offset range, matching `typst_pdf::Timestamp::new_local`.
129fn valid_offset(minutes: i32) -> bool {
130    (-(23 * 60 + 59)..=(23 * 60 + 59)).contains(&minutes)
131}
132
133impl PdfTimestamp {
134    /// The current time in UTC.
135    ///
136    /// This is the common case. It never panics: if the system clock predates the Unix
137    /// epoch, it saturates to `1970-01-01T00:00:00Z`.
138    pub fn now_utc() -> Self {
139        let (year, month, day, hour, minute, second) = civil_from_unix(now_unix_secs());
140        Self {
141            year,
142            month,
143            day,
144            hour,
145            minute,
146            second,
147            offset_minutes: None,
148        }
149    }
150
151    /// The current time expressed as wall-clock time at the given UTC offset (in minutes).
152    ///
153    /// For example, `now_local(540)` yields the current time in UTC+09:00 (KST).
154    /// Returns `None` if the offset is outside ±(23h, 59m).
155    pub fn now_local(offset_minutes: i32) -> Option<Self> {
156        if !valid_offset(offset_minutes) {
157            return None;
158        }
159        let (year, month, day, hour, minute, second) =
160            civil_from_unix(now_unix_secs() + offset_minutes as i64 * 60);
161        Some(Self {
162            year,
163            month,
164            day,
165            hour,
166            minute,
167            second,
168            offset_minutes: Some(offset_minutes),
169        })
170    }
171
172    /// A specific UTC date and time.
173    ///
174    /// Returns `None` if the date or time is invalid (e.g. month 13).
175    pub fn utc(year: i32, month: u8, day: u8, hour: u8, minute: u8, second: u8) -> Option<Self> {
176        // Validate via typst's calendar logic.
177        typst::foundations::Datetime::from_ymd_hms(year, month, day, hour, minute, second)?;
178        Some(Self {
179            year,
180            month,
181            day,
182            hour,
183            minute,
184            second,
185            offset_minutes: None,
186        })
187    }
188
189    /// A specific date and time at the given UTC offset (in minutes).
190    ///
191    /// Returns `None` if the date/time is invalid or the offset is outside ±(23h, 59m).
192    pub fn local(
193        year: i32,
194        month: u8,
195        day: u8,
196        hour: u8,
197        minute: u8,
198        second: u8,
199        offset_minutes: i32,
200    ) -> Option<Self> {
201        typst::foundations::Datetime::from_ymd_hms(year, month, day, hour, minute, second)?;
202        if !valid_offset(offset_minutes) {
203            return None;
204        }
205        Some(Self {
206            year,
207            month,
208            day,
209            hour,
210            minute,
211            second,
212            offset_minutes: Some(offset_minutes),
213        })
214    }
215
216    /// Convert to a typst timestamp. Fields were validated at construction, so this
217    /// returns `Some` on the normal path; a `None` is surfaced as an error upstream
218    /// rather than panicking.
219    fn to_typst(self) -> Option<typst_pdf::Timestamp> {
220        let datetime = typst::foundations::Datetime::from_ymd_hms(
221            self.year,
222            self.month,
223            self.day,
224            self.hour,
225            self.minute,
226            self.second,
227        )?;
228        match self.offset_minutes {
229            None => Some(typst_pdf::Timestamp::new_utc(datetime)),
230            Some(offset) => typst_pdf::Timestamp::new_local(datetime, offset),
231        }
232    }
233}
234
235/// Read the current Unix time in seconds, saturating to 0 if the clock predates the epoch.
236fn now_unix_secs() -> i64 {
237    std::time::SystemTime::now()
238        .duration_since(std::time::UNIX_EPOCH)
239        .map(|d| d.as_secs() as i64)
240        .unwrap_or(0)
241}
242
243/// Convert Unix time (seconds) to `(year, month, day, hour, minute, second)` in UTC.
244///
245/// Uses Howard Hinnant's `civil_from_days` algorithm. Euclidean division/remainder are
246/// used so pre-epoch (negative) inputs are handled correctly.
247fn civil_from_unix(secs: i64) -> (i32, u8, u8, u8, u8, u8) {
248    let days = secs.div_euclid(86_400);
249    let rem = secs.rem_euclid(86_400);
250    let hour = (rem / 3_600) as u8;
251    let minute = ((rem % 3_600) / 60) as u8;
252    let second = (rem % 60) as u8;
253
254    // Howard Hinnant's civil_from_days (days are relative to 1970-01-01).
255    let z = days + 719_468;
256    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
257    let doe = z - era * 146_097; // [0, 146096]
258    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
259    let y = yoe + era * 400;
260    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
261    let mp = (5 * doy + 2) / 153; // [0, 11]
262    let day = (doy - (153 * mp + 2) / 5 + 1) as u8; // [1, 31]
263    let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u8; // [1, 12]
264    let year = (y + if month <= 2 { 1 } else { 0 }) as i32;
265
266    (year, month, day, hour, minute, second)
267}
268
269/// PDF export configuration for [`Document::with_pdf_config`](crate::Document::with_pdf_config).
270///
271/// Construct with struct-update syntax over [`Default`]:
272/// ```
273/// use typst_bake::{PdfConfig, PdfStandard, PdfTimestamp};
274///
275/// let config = PdfConfig {
276///     tagged: false, // smaller PDF; bookmarks/outline are preserved
277///     standard: PdfStandard::A2b,
278///     ident: Some("invoice-2026-001".into()),
279///     timestamp: Some(PdfTimestamp::now_utc()),
280///     ..Default::default()
281/// };
282/// assert!(!config.tagged);
283/// ```
284///
285/// [`PdfConfig::default()`] maps exactly to typst's default PDF options (tagged PDF,
286/// PDF 1.7, auto identifier, no explicit timestamp), so leaving it untouched does not
287/// change output.
288#[derive(Clone, Debug)]
289pub struct PdfConfig {
290    /// PDF conformance standard. Defaults to [`PdfStandard::V1_7`].
291    pub standard: PdfStandard,
292    /// Whether to emit a tagged PDF (accessibility structure tree).
293    ///
294    /// Defaults to `true`, matching typst. Set to `false` to reduce file size; the
295    /// document outline (bookmarks) is independent of tagging and is preserved.
296    /// Standards that require tagging (the accessible levels `A1a`/`A2a`/`A3a` and `Ua1`)
297    /// reject `false`.
298    pub tagged: bool,
299    /// A stable document identifier. `None` (the default) lets typst derive one
300    /// automatically. Must not be empty.
301    pub ident: Option<String>,
302    /// The document creation timestamp. Applied only when the template's document date
303    /// is `auto`. Required for any PDF/A standard (which mandates a document date) unless
304    /// the template sets the date itself.
305    pub timestamp: Option<PdfTimestamp>,
306}
307
308impl Default for PdfConfig {
309    fn default() -> Self {
310        // `tagged: true` mirrors typst's default; using `bool::default()` (false) would
311        // silently change output for users who don't set a config.
312        Self {
313            standard: PdfStandard::default(),
314            tagged: true,
315            ident: None,
316            timestamp: None,
317        }
318    }
319}
320
321impl PdfConfig {
322    /// Convert to typst PDF options. Borrows `self` for the `ident` string.
323    ///
324    /// `page_ranges` is always `None` here; page selection is applied by the renderer.
325    pub(crate) fn to_typst(&self) -> Result<typst_pdf::PdfOptions<'_>> {
326        use typst::foundations::Smart;
327
328        // An accessible standard requires tagging; `tagged: false` would be rejected by
329        // typst-pdf anyway, so fail early with a clear message.
330        if self.standard.requires_tagging() && !self.tagged {
331            return Err(Error::InvalidPdfConfig(format!(
332                "{:?} requires tagged PDF; remove `tagged: false`",
333                self.standard
334            )));
335        }
336        // An empty identifier would undermine the PDF/A stable-ID guarantee.
337        if matches!(&self.ident, Some(s) if s.is_empty()) {
338            return Err(Error::InvalidPdfConfig(
339                "ident must not be empty; use None for an automatic identifier".into(),
340            ));
341        }
342
343        let standards = typst_pdf::PdfStandards::new(&[self.standard.to_typst()])
344            .map_err(|e| Error::InvalidPdfConfig(e.to_string()))?;
345
346        let timestamp = match self.timestamp {
347            Some(ts) => Some(
348                ts.to_typst()
349                    .ok_or_else(|| Error::InvalidPdfConfig("invalid timestamp".into()))?,
350            ),
351            None => None,
352        };
353
354        Ok(typst_pdf::PdfOptions {
355            ident: self
356                .ident
357                .as_deref()
358                .map(Smart::Custom)
359                .unwrap_or(Smart::Auto),
360            timestamp,
361            page_ranges: None,
362            standards,
363            tagged: self.tagged,
364        })
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn default_config_matches_typst_defaults() {
374        let cfg = PdfConfig::default();
375        let opts = cfg.to_typst().unwrap();
376        assert!(opts.tagged);
377        assert!(matches!(opts.ident, typst::foundations::Smart::Auto));
378        assert!(opts.timestamp.is_none());
379        assert!(opts.page_ranges.is_none());
380    }
381
382    #[test]
383    fn tagged_false_is_ok_for_basic_standards() {
384        let cfg = PdfConfig {
385            tagged: false,
386            standard: PdfStandard::A2b,
387            ..Default::default()
388        };
389        assert!(cfg.to_typst().is_ok());
390    }
391
392    #[test]
393    fn accessible_standard_rejects_untagged() {
394        for standard in [
395            PdfStandard::A1a,
396            PdfStandard::A2a,
397            PdfStandard::A3a,
398            PdfStandard::Ua1,
399        ] {
400            let cfg = PdfConfig {
401                tagged: false,
402                standard,
403                ..Default::default()
404            };
405            assert!(matches!(cfg.to_typst(), Err(Error::InvalidPdfConfig(_))));
406        }
407    }
408
409    #[test]
410    fn empty_ident_is_rejected() {
411        let cfg = PdfConfig {
412            ident: Some(String::new()),
413            ..Default::default()
414        };
415        assert!(matches!(cfg.to_typst(), Err(Error::InvalidPdfConfig(_))));
416    }
417
418    #[test]
419    fn representative_standards_convert() {
420        for standard in [PdfStandard::A2b, PdfStandard::V2_0, PdfStandard::A4] {
421            let cfg = PdfConfig {
422                standard,
423                ..Default::default()
424            };
425            assert!(cfg.to_typst().is_ok());
426        }
427    }
428
429    #[test]
430    fn timestamp_constructors() {
431        assert!(PdfTimestamp::utc(2026, 6, 6, 12, 0, 0).is_some());
432        assert!(PdfTimestamp::utc(2026, 13, 1, 0, 0, 0).is_none());
433        assert!(PdfTimestamp::local(2026, 6, 6, 12, 0, 0, 540).is_some());
434        assert!(PdfTimestamp::now_local(99 * 60).is_none());
435        // now_utc is infallible and converts cleanly.
436        assert!(PdfTimestamp::now_utc().to_typst().is_some());
437    }
438
439    #[test]
440    fn now_local_offset_shifts_wall_clock() {
441        // At the same instant, +60min wall clock is one hour ahead of UTC (modulo day wrap).
442        let utc = PdfTimestamp::now_utc();
443        let local = PdfTimestamp::now_local(60).unwrap();
444        let utc_minutes = utc.hour as i32 * 60 + utc.minute as i32;
445        let local_minutes = local.hour as i32 * 60 + local.minute as i32;
446        let diff = (local_minutes - utc_minutes).rem_euclid(24 * 60);
447        // Allow a 1-minute slack for the (tiny) time between the two clock reads.
448        assert!(diff == 60 || diff == 59 || diff == 61, "diff was {diff}");
449    }
450
451    #[test]
452    fn civil_from_unix_known_values() {
453        assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0));
454        // 2026-06-06T12:00:00Z
455        assert_eq!(civil_from_unix(1_780_747_200), (2026, 6, 6, 12, 0, 0));
456        // Leap day 2000-02-29 (year-2000 is a leap year).
457        assert_eq!(civil_from_unix(951_782_400), (2000, 2, 29, 0, 0, 0));
458        // 2100-03-01 (year-2100 is NOT a leap year, so Feb has 28 days).
459        assert_eq!(civil_from_unix(4_107_542_400), (2100, 3, 1, 0, 0, 0));
460        // Pre-epoch: 1969-12-31T23:59:59Z.
461        assert_eq!(civil_from_unix(-1), (1969, 12, 31, 23, 59, 59));
462    }
463}