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}