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}