Skip to main content

typst_bake/
document.rs

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