typst_bake/document.rs
1//! Self-contained document for Typst template rendering.
2
3use crate::error::{Error, Result};
4#[cfg(feature = "pdf")]
5use crate::pdf_config::PdfConfig;
6use crate::resolver::{normalize_file_path, EmbeddedResolver};
7use crate::stats::EmbedStats;
8use crate::util::decompress;
9use include_dir::{Dir, File};
10use std::collections::{BTreeSet, HashMap};
11use std::sync::{Mutex, MutexGuard};
12use typst::foundations::Dict;
13use typst::layout::PagedDocument;
14use typst_as_lib::{TypstAsLibError, TypstEngine};
15
16/// A fully self-contained document ready for rendering.
17///
18/// Created by the [`document!`](crate::document!) macro with embedded templates, fonts,
19/// and packages. All resources are compressed with zstd and decompressed lazily at runtime.
20pub struct Document {
21 templates: &'static Dir<'static>,
22 packages: &'static Dir<'static>,
23 fonts: &'static Dir<'static>,
24 entry: &'static str,
25 inputs: Mutex<Option<Dict>>,
26 runtime_files: Mutex<HashMap<String, Vec<u8>>>,
27 stats: EmbedStats,
28 compiled_cache: Mutex<Option<PagedDocument>>,
29 /// PDF export options. Set by [`Document::with_pdf_config`]. A plain field (no
30 /// `Mutex`): the builder takes `self` by value to write it, and rendering reads it
31 /// through `&self`. Affects PDF export only, so it never invalidates `compiled_cache`.
32 #[cfg(feature = "pdf")]
33 pdf_config: PdfConfig,
34}
35
36impl Document {
37 /// Internal constructor used by the macro.
38 /// Do not use directly.
39 #[doc(hidden)]
40 pub fn __new(
41 templates: &'static Dir<'static>,
42 packages: &'static Dir<'static>,
43 fonts: &'static Dir<'static>,
44 entry: &'static str,
45 stats: EmbedStats,
46 ) -> Self {
47 Self {
48 templates,
49 packages,
50 fonts,
51 entry,
52 inputs: Mutex::new(None),
53 runtime_files: Mutex::new(HashMap::new()),
54 stats,
55 compiled_cache: Mutex::new(None),
56 #[cfg(feature = "pdf")]
57 pdf_config: PdfConfig::default(),
58 }
59 }
60
61 fn lock_inputs(&self) -> MutexGuard<'_, Option<Dict>> {
62 self.inputs.lock().expect("lock poisoned")
63 }
64
65 fn lock_runtime_files(&self) -> MutexGuard<'_, HashMap<String, Vec<u8>>> {
66 self.runtime_files.lock().expect("lock poisoned")
67 }
68
69 fn lock_cache(&self) -> MutexGuard<'_, Option<PagedDocument>> {
70 self.compiled_cache.lock().expect("lock poisoned")
71 }
72
73 /// Add input data to the document.
74 ///
75 /// Define your data structs using the derive macros:
76 /// - **Top-level struct**: Use both [`IntoValue`](crate::IntoValue) and [`IntoDict`](crate::IntoDict)
77 /// - **Nested structs**: Use [`IntoValue`](crate::IntoValue) only
78 ///
79 /// In `.typ` files, access the data via `sys.inputs`:
80 /// ```typ
81 /// #import sys: inputs
82 /// = #inputs.title
83 /// ```
84 ///
85 /// # Example
86 ///
87 /// ```rust,ignore
88 /// use typst_bake::{IntoValue, IntoDict};
89 ///
90 /// #[derive(IntoValue, IntoDict)] // Top-level: both macros
91 /// struct Inputs {
92 /// title: String,
93 /// products: Vec<Product>,
94 /// }
95 ///
96 /// #[derive(IntoValue)] // Nested: IntoValue only
97 /// struct Product {
98 /// name: String,
99 /// price: f64,
100 /// }
101 ///
102 /// let inputs = Inputs {
103 /// title: "Catalog".to_string(),
104 /// products: vec![
105 /// Product { name: "Apple".to_string(), price: 1.50 },
106 /// ],
107 /// };
108 ///
109 /// let pdf = typst_bake::document!("main.typ")
110 /// .with_inputs(inputs)
111 /// .to_pdf()?;
112 /// ```
113 pub fn with_inputs<T: Into<Dict>>(self, inputs: T) -> Self {
114 *self.lock_inputs() = Some(inputs.into());
115 *self.lock_cache() = None;
116 self
117 }
118
119 /// Add or replace a runtime file at the given path.
120 ///
121 /// The file becomes available to Typst templates via `#image("path")`,
122 /// `#read("path")`, etc. Runtime files take priority over embedded files
123 /// with the same path.
124 ///
125 /// # Errors
126 /// Returns [`Error::InvalidFilePath`] if the path is empty, absolute, or
127 /// contains `..` segments.
128 ///
129 /// # Example
130 /// ```rust,ignore
131 /// let pdf = typst_bake::document!("main.typ")
132 /// .add_file("images/chart.png", chart_bytes)?
133 /// .to_pdf()?;
134 /// ```
135 pub fn add_file(self, path: impl Into<String>, data: impl Into<Vec<u8>>) -> Result<Self> {
136 let raw = path.into();
137 let normalized = normalize_file_path(&raw);
138
139 if normalized.is_empty() {
140 return Err(Error::InvalidFilePath("path is empty".into()));
141 }
142 if normalized.starts_with('/') {
143 return Err(Error::InvalidFilePath(format!(
144 "absolute path not allowed: {normalized}"
145 )));
146 }
147 if normalized.split('/').any(|s| s == "..") {
148 return Err(Error::InvalidFilePath(format!(
149 "path with '..' not allowed: {normalized}"
150 )));
151 }
152
153 self.lock_runtime_files().insert(normalized, data.into());
154 *self.lock_cache() = None;
155 Ok(self)
156 }
157
158 /// Set PDF export options.
159 ///
160 /// Configures PDF-only settings such as tagging, conformance standard, document
161 /// identifier, and creation timestamp. See [`PdfConfig`]. These options affect
162 /// [`to_pdf`](Self::to_pdf) only; SVG/PNG output ignores them.
163 ///
164 /// This does not invalidate the compiled cache (options apply at the PDF export
165 /// stage, not during compilation). Invalid configurations are reported when
166 /// [`to_pdf`](Self::to_pdf) is called, not here; the default config never errors.
167 ///
168 /// # Example
169 /// ```rust,ignore
170 /// use typst_bake::{PdfConfig, PdfStandard};
171 ///
172 /// // Disable tagging to shrink the PDF (bookmarks are preserved).
173 /// let pdf = typst_bake::document!("main.typ")
174 /// .with_pdf_config(PdfConfig {
175 /// tagged: false,
176 /// standard: PdfStandard::A2b,
177 /// ..Default::default()
178 /// })
179 /// .to_pdf()?;
180 /// ```
181 ///
182 /// Note: a page selection (via [`select_pages`](Self::select_pages)) always forces
183 /// tagging off and drops bookmarks for excluded pages; prefer `tagged: false` on the
184 /// full document if you only want to disable tagging.
185 #[cfg(feature = "pdf")]
186 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
187 pub fn with_pdf_config(mut self, config: PdfConfig) -> Self {
188 self.pdf_config = config;
189 self
190 }
191
192 /// Check if a file exists at the given path.
193 ///
194 /// Checks both embedded (compile-time) and runtime files.
195 pub fn has_file(&self, path: impl AsRef<str>) -> bool {
196 let normalized = normalize_file_path(path.as_ref());
197
198 // Check runtime files first.
199 if self.lock_runtime_files().contains_key(&normalized) {
200 return true;
201 }
202
203 // Check embedded templates.
204 if find_entry(self.templates, &normalized).is_some() {
205 return true;
206 }
207
208 false
209 }
210
211 /// Select specific pages for output, returning a [`Pages`] view.
212 ///
213 /// Pages are 0-indexed. Duplicates are removed and pages are always
214 /// output in document order regardless of input order.
215 ///
216 /// # Errors
217 /// Returns [`Error::InvalidPageSelection`] at render time if any index
218 /// is out of range or the selection is empty.
219 ///
220 /// # Example
221 /// ```rust,ignore
222 /// // Select specific pages
223 /// let pdf = typst_bake::document!("main.typ")
224 /// .select_pages([0, 2, 4])
225 /// .to_pdf()?;
226 ///
227 /// // Works with ranges too
228 /// let svgs = typst_bake::document!("main.typ")
229 /// .select_pages(0..3)
230 /// .to_svg()?;
231 ///
232 /// // Reuse with different selections
233 /// let doc = typst_bake::document!("main.typ");
234 /// let cover = doc.select_pages([0]).to_pdf()?;
235 /// let body = doc.select_pages(1..5).to_pdf()?;
236 /// ```
237 pub fn select_pages(&self, pages: impl IntoIterator<Item = usize>) -> Pages<'_> {
238 Pages {
239 doc: self,
240 indices: pages.into_iter().collect(),
241 }
242 }
243
244 /// Get the total number of pages in the compiled document.
245 ///
246 /// Compiles the document if not already compiled.
247 /// Returns the total page count regardless of `select_pages`.
248 ///
249 /// # Example
250 /// ```rust,ignore
251 /// let doc = typst_bake::document!("main.typ");
252 /// let count = doc.page_count()?;
253 /// let last_page = doc.select_pages([count - 1]).to_pdf()?;
254 /// ```
255 pub fn page_count(&self) -> Result<usize> {
256 self.with_compiled(|compiled| Ok(compiled.pages.len()))
257 }
258
259 /// Get compression statistics for embedded content.
260 pub fn stats(&self) -> &EmbedStats {
261 &self.stats
262 }
263
264 /// Compile the document, reusing the cached result if available.
265 fn compile_cached(&self) -> Result<()> {
266 if self.lock_cache().is_some() {
267 return Ok(());
268 }
269
270 // Read main template content (compressed)
271 let main_file =
272 find_entry(self.templates, self.entry).ok_or(Error::EntryNotFound(self.entry))?;
273
274 let main_bytes = decompress(main_file.contents())?;
275 let main_content = std::str::from_utf8(&main_bytes).map_err(|_| Error::InvalidUtf8)?;
276
277 let mut resolver = EmbeddedResolver::new(self.templates, self.packages);
278 for (path, data) in self.lock_runtime_files().iter() {
279 resolver.insert_runtime_file(path.clone(), data.clone());
280 }
281
282 // Collect and decompress fonts from the embedded fonts directory
283 let font_data: Vec<Vec<u8>> = self
284 .fonts
285 .files()
286 .map(|f| decompress(f.contents()).map_err(Error::from))
287 .collect::<Result<Vec<_>>>()?;
288
289 let font_refs: Vec<&[u8]> = font_data.iter().map(Vec::as_slice).collect();
290
291 let engine = TypstEngine::builder()
292 .main_file((self.entry, main_content))
293 .add_file_resolver(resolver)
294 .fonts(font_refs)
295 .build();
296
297 // Clone inputs (preserve for retry on failure)
298 let inputs = self.lock_inputs().clone();
299
300 let warned_result = if let Some(inputs) = inputs {
301 engine.compile_with_input::<_, PagedDocument>(inputs)
302 } else {
303 engine.compile::<PagedDocument>()
304 };
305
306 // Handle the Warned wrapper and extract result
307 let compiled = warned_result.output.map_err(|e| {
308 let msg = match e {
309 TypstAsLibError::TypstSource(diagnostics) => diagnostics
310 .iter()
311 .map(|d| d.message.as_str())
312 .collect::<Vec<_>>()
313 .join("\n"),
314 other => other.to_string(),
315 };
316 Error::Compilation(msg)
317 })?;
318
319 *self.lock_cache() = Some(compiled);
320
321 Ok(())
322 }
323
324 /// Compile if needed, then call `f` with a reference to the compiled document.
325 fn with_compiled<F, T>(&self, f: F) -> Result<T>
326 where
327 F: FnOnce(&PagedDocument) -> Result<T>,
328 {
329 self.compile_cached()?;
330 let cache = self.lock_cache();
331 let compiled = cache
332 .as_ref()
333 .expect("compiled_cache must be Some after successful compile_cached()");
334 f(compiled)
335 }
336
337 /// Compile the document and generate PDF.
338 ///
339 /// # Returns
340 /// PDF data as bytes.
341 ///
342 /// # Errors
343 /// Returns an error if compilation or PDF generation fails.
344 #[cfg(feature = "pdf")]
345 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
346 pub fn to_pdf(&self) -> Result<Vec<u8>> {
347 self.render_pdf(None)
348 }
349
350 /// Compile the document and generate SVG for each page.
351 ///
352 /// # Returns
353 /// A vector of SVG strings, one per page.
354 ///
355 /// # Errors
356 /// Returns an error if compilation fails.
357 #[cfg(feature = "svg")]
358 #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
359 pub fn to_svg(&self) -> Result<Vec<String>> {
360 self.render_svg(None)
361 }
362
363 /// Compile the document and generate PNG for each page.
364 ///
365 /// # Arguments
366 /// * `dpi` - Resolution in dots per inch (e.g., 72 for 1:1, 144 for Retina, 300 for print)
367 ///
368 /// # Returns
369 /// A vector of PNG bytes, one per page.
370 ///
371 /// # Errors
372 /// Returns an error if compilation or PNG encoding fails.
373 #[cfg(feature = "png")]
374 #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
375 pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
376 self.render_png(None, dpi)
377 }
378
379 #[cfg(feature = "pdf")]
380 fn render_pdf(&self, selected: Option<&BTreeSet<usize>>) -> Result<Vec<u8>> {
381 self.with_compiled(|compiled| {
382 // Base options come from the stored config (incl. `tagged`, standard, ident,
383 // timestamp). `options` borrows `self.pdf_config.ident`; later reads of
384 // `self.pdf_config.standard` (Copy) are additional shared borrows, which is
385 // fine. We are inside the `compiled_cache` guard, but `pdf_config` is a
386 // distinct field accessed only by shared borrow — so this is sound. Nobody
387 // must take `&mut self.pdf_config` here.
388 let mut options = self.pdf_config.to_typst()?;
389
390 let indices = validate_page_selection(selected, compiled.pages.len())?;
391 if let Some(indices) = indices {
392 use std::num::NonZeroUsize;
393 use typst::layout::PageRanges;
394
395 let ranges = indices
396 .iter()
397 .map(|&i| {
398 let n = Some(NonZeroUsize::new(i + 1).unwrap());
399 n..=n
400 })
401 .collect();
402 options.page_ranges = Some(PageRanges::new(ranges));
403
404 // --- Single safety net: page selection forces tagging off ---
405 //
406 // Page selection sets `page_ranges`, which is incompatible with tagged
407 // PDF. typst-pdf does NOT error on `tagged: true` + `page_ranges`; it
408 // silently emits a structure tree referencing ALL pages while only a
409 // subset is exported, yielding a malformed/misaligned tag tree. See:
410 // - typst/typst#7743 (tagged PDF incompatible with page ranges)
411 // So we defensively force tagging off here, overriding `PdfConfig.tagged`
412 // — but ONLY on the page-selection path. Full-document `tagged: false` is
413 // handled by `to_typst` and is unaffected.
414 //
415 // Bookmarks: the document outline (/Outlines) is independent of tagging
416 // (typst-pdf sets the outline unconditionally; the tag tree only when
417 // enabled), so disabling tagging keeps bookmarks. Bookmarks pointing at
418 // EXCLUDED pages are still dropped here — that loss is caused by
419 // `page_ranges`, not by tagging.
420 //
421 // Accessible standards (PDF/A-*a, PDF/UA-1) mandate tagging, so they
422 // cannot coexist with page selection; reject them explicitly rather than
423 // emit a non-conformant PDF.
424 if self.pdf_config.standard.requires_tagging() {
425 return Err(Error::InvalidPdfConfig(format!(
426 "page selection is incompatible with {:?} (requires tagging)",
427 self.pdf_config.standard
428 )));
429 }
430 options.tagged = false;
431 }
432
433 // Invariant backstop: tagged PDF + page ranges must never escape together.
434 debug_assert!(!(options.tagged && options.page_ranges.is_some()));
435
436 typst_pdf::pdf(compiled, &options).map_err(|e| Error::PdfGeneration(format!("{e:?}")))
437 })
438 }
439
440 #[cfg(feature = "svg")]
441 fn render_svg(&self, selected: Option<&BTreeSet<usize>>) -> Result<Vec<String>> {
442 self.with_compiled(|compiled| {
443 let indices = validate_page_selection(selected, compiled.pages.len())?;
444 match indices {
445 Some(indices) => Ok(indices
446 .iter()
447 .map(|&i| typst_svg::svg(&compiled.pages[i]))
448 .collect()),
449 None => Ok(compiled.pages.iter().map(typst_svg::svg).collect()),
450 }
451 })
452 }
453
454 #[cfg(feature = "png")]
455 fn render_png(&self, selected: Option<&BTreeSet<usize>>, dpi: f32) -> Result<Vec<Vec<u8>>> {
456 self.with_compiled(|compiled| {
457 let pixel_per_pt = dpi / 72.0;
458 let indices = validate_page_selection(selected, compiled.pages.len())?;
459 let pages: Box<dyn Iterator<Item = &_>> = match &indices {
460 Some(indices) => Box::new(indices.iter().map(|&i| &compiled.pages[i])),
461 None => Box::new(compiled.pages.iter()),
462 };
463 pages
464 .map(|page| {
465 typst_render::render(page, pixel_per_pt)
466 .encode_png()
467 .map_err(|e| Error::PngEncoding(e.to_string()))
468 })
469 .collect()
470 })
471 }
472}
473
474/// A lightweight view into a [`Document`] with a page selection filter.
475///
476/// Created by [`Document::select_pages`]. Holds a reference to the
477/// document and an owned set of page indices.
478pub struct Pages<'a> {
479 doc: &'a Document,
480 indices: BTreeSet<usize>,
481}
482
483impl Pages<'_> {
484 /// Compile the document and generate PDF for the selected pages.
485 ///
486 /// # Errors
487 /// Returns an error if compilation, PDF generation, or page selection fails.
488 #[cfg(feature = "pdf")]
489 #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
490 pub fn to_pdf(&self) -> Result<Vec<u8>> {
491 self.doc.render_pdf(Some(&self.indices))
492 }
493
494 /// Compile the document and generate SVG for the selected pages.
495 ///
496 /// # Errors
497 /// Returns an error if compilation or page selection fails.
498 #[cfg(feature = "svg")]
499 #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
500 pub fn to_svg(&self) -> Result<Vec<String>> {
501 self.doc.render_svg(Some(&self.indices))
502 }
503
504 /// Compile the document and generate PNG for the selected pages.
505 ///
506 /// # Arguments
507 /// * `dpi` - Resolution in dots per inch (e.g., 72 for 1:1, 144 for Retina, 300 for print)
508 ///
509 /// # Errors
510 /// Returns an error if compilation, PNG encoding, or page selection fails.
511 #[cfg(feature = "png")]
512 #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
513 pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
514 self.doc.render_png(Some(&self.indices), dpi)
515 }
516}
517
518/// Validate page selection and return indices to render.
519/// Returns `None` if no selection (= all pages).
520fn validate_page_selection(
521 selected: Option<&BTreeSet<usize>>,
522 total_pages: usize,
523) -> Result<Option<Vec<usize>>> {
524 if total_pages == 0 {
525 return Err(Error::InvalidPageSelection("document has no pages".into()));
526 }
527 match selected {
528 None => Ok(None),
529 Some(pages) => {
530 if pages.is_empty() {
531 return Err(Error::InvalidPageSelection(
532 "page selection is empty".into(),
533 ));
534 }
535 if let Some(&max) = pages.last() {
536 if max >= total_pages {
537 return Err(Error::InvalidPageSelection(format!(
538 "page index {max} out of range (valid: 0..={})",
539 total_pages - 1
540 )));
541 }
542 }
543 Ok(Some(pages.iter().copied().collect()))
544 }
545 }
546}
547
548/// Find a file in a `Dir` tree by a potentially nested path (e.g. "dir/main.typ").
549fn find_entry<'a>(dir: &'a Dir<'a>, path: &str) -> Option<&'a File<'a>> {
550 let normalized = path.trim_start_matches("./").replace('\\', "/");
551 let (dir_path, file_name) = match normalized.rsplit_once('/') {
552 Some((d, f)) => (Some(d), f),
553 None => (None, normalized.as_str()),
554 };
555
556 let target_dir = match dir_path {
557 Some(dir_path) => {
558 let mut current = dir;
559 for segment in dir_path.split('/') {
560 current = current
561 .dirs()
562 .find(|d| d.path().file_name().and_then(|n| n.to_str()) == Some(segment))?;
563 }
564 current
565 }
566 None => dir,
567 };
568
569 target_dir
570 .files()
571 .find(|f| f.path().file_name().and_then(|n| n.to_str()) == Some(file_name))
572}