Skip to main content

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}