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    /// The producing application, written to the PDF `/Creator` metadata field.
307    ///
308    /// `None` (the default) keeps typst's own `"Typst x.y.z"` string. Set this to
309    /// identify your own application instead.
310    pub creator: Option<String>,
311}
312
313impl Default for PdfConfig {
314    fn default() -> Self {
315        // `tagged: true` mirrors typst's default; using `bool::default()` (false) would
316        // silently change output for users who don't set a config.
317        Self {
318            standard: PdfStandard::default(),
319            tagged: true,
320            ident: None,
321            timestamp: None,
322            creator: None,
323        }
324    }
325}
326
327/// Render a typst `HintedString` as a single message.
328///
329/// `HintedString` dropped its `Display` impl in typst 0.15, so join the message with
330/// its hints by hand.
331fn hinted_to_string(error: &typst::diag::HintedString) -> String {
332    let mut out = error.message().to_string();
333    for hint in error.hints() {
334        out.push_str("; hint: ");
335        out.push_str(hint);
336    }
337    out
338}
339
340impl PdfConfig {
341    /// Convert to typst PDF options.
342    ///
343    /// `page_ranges` is always `None` here; page selection is applied by the renderer.
344    pub(crate) fn to_typst(&self) -> Result<typst_pdf::PdfOptions> {
345        use typst::foundations::Smart;
346
347        // An accessible standard requires tagging; `tagged: false` would be rejected by
348        // typst-pdf anyway, so fail early with a clear message.
349        if self.standard.requires_tagging() && !self.tagged {
350            return Err(Error::InvalidPdfConfig(format!(
351                "{:?} requires tagged PDF; remove `tagged: false`",
352                self.standard
353            )));
354        }
355        // An empty identifier would undermine the PDF/A stable-ID guarantee.
356        if matches!(&self.ident, Some(s) if s.is_empty()) {
357            return Err(Error::InvalidPdfConfig(
358                "ident must not be empty; use None for an automatic identifier".into(),
359            ));
360        }
361
362        let standards = typst_pdf::PdfStandards::new(&[self.standard.to_typst()])
363            .map_err(|e| Error::InvalidPdfConfig(hinted_to_string(&e)))?;
364
365        let timestamp = match self.timestamp {
366            Some(ts) => Some(
367                ts.to_typst()
368                    .ok_or_else(|| Error::InvalidPdfConfig("invalid timestamp".into()))?,
369            ),
370            None => None,
371        };
372
373        Ok(typst_pdf::PdfOptions {
374            ident: self.ident.clone().map(Smart::Custom).unwrap_or(Smart::Auto),
375            creator: self
376                .creator
377                .clone()
378                .map(|c| Smart::Custom(Some(c)))
379                .unwrap_or(Smart::Auto),
380            pretty: false,
381            timestamp,
382            page_ranges: None,
383            standards,
384            tagged: self.tagged,
385        })
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn default_config_matches_typst_defaults() {
395        let cfg = PdfConfig::default();
396        let opts = cfg.to_typst().unwrap();
397        assert!(opts.tagged);
398        assert!(matches!(opts.ident, typst::foundations::Smart::Auto));
399        // `Auto` leaves typst's own "Typst x.y.z" creator string in place.
400        assert!(matches!(opts.creator, typst::foundations::Smart::Auto));
401        assert!(opts.timestamp.is_none());
402        assert!(opts.page_ranges.is_none());
403    }
404
405    #[test]
406    fn custom_creator_is_forwarded() {
407        let cfg = PdfConfig {
408            creator: Some("My Invoice App 1.0".into()),
409            ..Default::default()
410        };
411        let opts = cfg.to_typst().unwrap();
412        match opts.creator {
413            typst::foundations::Smart::Custom(Some(c)) => assert_eq!(c, "My Invoice App 1.0"),
414            other => panic!("expected a custom creator, got {other:?}"),
415        }
416    }
417
418    #[test]
419    fn tagged_false_is_ok_for_basic_standards() {
420        let cfg = PdfConfig {
421            tagged: false,
422            standard: PdfStandard::A2b,
423            ..Default::default()
424        };
425        assert!(cfg.to_typst().is_ok());
426    }
427
428    #[test]
429    fn accessible_standard_rejects_untagged() {
430        for standard in [
431            PdfStandard::A1a,
432            PdfStandard::A2a,
433            PdfStandard::A3a,
434            PdfStandard::Ua1,
435        ] {
436            let cfg = PdfConfig {
437                tagged: false,
438                standard,
439                ..Default::default()
440            };
441            assert!(matches!(cfg.to_typst(), Err(Error::InvalidPdfConfig(_))));
442        }
443    }
444
445    #[test]
446    fn empty_ident_is_rejected() {
447        let cfg = PdfConfig {
448            ident: Some(String::new()),
449            ..Default::default()
450        };
451        assert!(matches!(cfg.to_typst(), Err(Error::InvalidPdfConfig(_))));
452    }
453
454    #[test]
455    fn representative_standards_convert() {
456        for standard in [PdfStandard::A2b, PdfStandard::V2_0, PdfStandard::A4] {
457            let cfg = PdfConfig {
458                standard,
459                ..Default::default()
460            };
461            assert!(cfg.to_typst().is_ok());
462        }
463    }
464
465    #[test]
466    fn timestamp_constructors() {
467        assert!(PdfTimestamp::utc(2026, 6, 6, 12, 0, 0).is_some());
468        assert!(PdfTimestamp::utc(2026, 13, 1, 0, 0, 0).is_none());
469        assert!(PdfTimestamp::local(2026, 6, 6, 12, 0, 0, 540).is_some());
470        assert!(PdfTimestamp::now_local(99 * 60).is_none());
471        // now_utc is infallible and converts cleanly.
472        assert!(PdfTimestamp::now_utc().to_typst().is_some());
473    }
474
475    #[test]
476    fn now_local_offset_shifts_wall_clock() {
477        // At the same instant, +60min wall clock is one hour ahead of UTC (modulo day wrap).
478        let utc = PdfTimestamp::now_utc();
479        let local = PdfTimestamp::now_local(60).unwrap();
480        let utc_minutes = utc.hour as i32 * 60 + utc.minute as i32;
481        let local_minutes = local.hour as i32 * 60 + local.minute as i32;
482        let diff = (local_minutes - utc_minutes).rem_euclid(24 * 60);
483        // Allow a 1-minute slack for the (tiny) time between the two clock reads.
484        assert!(diff == 60 || diff == 59 || diff == 61, "diff was {diff}");
485    }
486
487    #[test]
488    fn civil_from_unix_known_values() {
489        assert_eq!(civil_from_unix(0), (1970, 1, 1, 0, 0, 0));
490        // 2026-06-06T12:00:00Z
491        assert_eq!(civil_from_unix(1_780_747_200), (2026, 6, 6, 12, 0, 0));
492        // Leap day 2000-02-29 (year-2000 is a leap year).
493        assert_eq!(civil_from_unix(951_782_400), (2000, 2, 29, 0, 0, 0));
494        // 2100-03-01 (year-2100 is NOT a leap year, so Feb has 28 days).
495        assert_eq!(civil_from_unix(4_107_542_400), (2100, 3, 1, 0, 0, 0));
496        // Pre-epoch: 1969-12-31T23:59:59Z.
497        assert_eq!(civil_from_unix(-1), (1969, 12, 31, 23, 59, 59));
498    }
499}