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::EmbeddedResolver;
5use crate::stats::EmbedStats;
6use crate::util::decompress;
7use include_dir::{Dir, File};
8use std::sync::{Mutex, MutexGuard};
9use typst::foundations::Dict;
10use typst::layout::PagedDocument;
11use typst_as_lib::{TypstAsLibError, TypstEngine};
12
13/// A fully self-contained document ready for rendering.
14///
15/// Created by the [`document!`](crate::document!) macro with embedded templates, fonts,
16/// and packages. All resources are compressed with zstd and decompressed lazily at runtime.
17pub struct Document {
18    templates: &'static Dir<'static>,
19    packages: &'static Dir<'static>,
20    fonts: &'static Dir<'static>,
21    entry: &'static str,
22    inputs: Mutex<Option<Dict>>,
23    stats: EmbedStats,
24    compiled_cache: Mutex<Option<PagedDocument>>,
25}
26
27impl Document {
28    /// Internal constructor used by the macro.
29    /// Do not use directly.
30    #[doc(hidden)]
31    pub fn __new(
32        templates: &'static Dir<'static>,
33        packages: &'static Dir<'static>,
34        fonts: &'static Dir<'static>,
35        entry: &'static str,
36        stats: EmbedStats,
37    ) -> Self {
38        Self {
39            templates,
40            packages,
41            fonts,
42            entry,
43            inputs: Mutex::new(None),
44            stats,
45            compiled_cache: Mutex::new(None),
46        }
47    }
48
49    fn lock_inputs(&self) -> MutexGuard<'_, Option<Dict>> {
50        self.inputs.lock().expect("lock poisoned")
51    }
52
53    fn lock_cache(&self) -> MutexGuard<'_, Option<PagedDocument>> {
54        self.compiled_cache.lock().expect("lock poisoned")
55    }
56
57    /// Add input data to the document.
58    ///
59    /// Define your data structs using the derive macros:
60    /// - **Top-level struct**: Use both [`IntoValue`](crate::IntoValue) and [`IntoDict`](crate::IntoDict)
61    /// - **Nested structs**: Use [`IntoValue`](crate::IntoValue) only
62    ///
63    /// In `.typ` files, access the data via `sys.inputs`:
64    /// ```typ
65    /// #import sys: inputs
66    /// = #inputs.title
67    /// ```
68    ///
69    /// # Example
70    ///
71    /// ```rust,ignore
72    /// use typst_bake::{IntoValue, IntoDict};
73    ///
74    /// #[derive(IntoValue, IntoDict)]  // Top-level: both macros
75    /// struct Inputs {
76    ///     title: String,
77    ///     products: Vec<Product>,
78    /// }
79    ///
80    /// #[derive(IntoValue)]  // Nested: IntoValue only
81    /// struct Product {
82    ///     name: String,
83    ///     price: f64,
84    /// }
85    ///
86    /// let inputs = Inputs {
87    ///     title: "Catalog".to_string(),
88    ///     products: vec![
89    ///         Product { name: "Apple".to_string(), price: 1.50 },
90    ///     ],
91    /// };
92    ///
93    /// let pdf = typst_bake::document!("main.typ")
94    ///     .with_inputs(inputs)
95    ///     .to_pdf()?;
96    /// ```
97    pub fn with_inputs<T: Into<Dict>>(self, inputs: T) -> Self {
98        *self.lock_inputs() = Some(inputs.into());
99        *self.lock_cache() = None;
100        self
101    }
102
103    /// Get compression statistics for embedded content.
104    pub fn stats(&self) -> &EmbedStats {
105        &self.stats
106    }
107
108    /// Compile the document, reusing the cached result if available.
109    fn compile_cached(&self) -> Result<()> {
110        if self.lock_cache().is_some() {
111            return Ok(());
112        }
113
114        // Read main template content (compressed)
115        let main_file =
116            find_entry(self.templates, self.entry).ok_or(Error::EntryNotFound(self.entry))?;
117
118        let main_bytes = decompress(main_file.contents())?;
119        let main_content = std::str::from_utf8(&main_bytes).map_err(|_| Error::InvalidUtf8)?;
120
121        let resolver = EmbeddedResolver::new(self.templates, self.packages);
122
123        // Collect and decompress fonts from the embedded fonts directory
124        let font_data: Vec<Vec<u8>> = self
125            .fonts
126            .files()
127            .map(|f| decompress(f.contents()).map_err(Error::from))
128            .collect::<Result<Vec<_>>>()?;
129
130        let font_refs: Vec<&[u8]> = font_data.iter().map(Vec::as_slice).collect();
131
132        let engine = TypstEngine::builder()
133            .main_file((self.entry, main_content))
134            .add_file_resolver(resolver)
135            .fonts(font_refs)
136            .build();
137
138        // Clone inputs (preserve for retry on failure)
139        let inputs = self.lock_inputs().clone();
140
141        let warned_result = if let Some(inputs) = inputs {
142            engine.compile_with_input::<_, PagedDocument>(inputs)
143        } else {
144            engine.compile::<PagedDocument>()
145        };
146
147        // Handle the Warned wrapper and extract result
148        let compiled = warned_result.output.map_err(|e| {
149            let msg = match e {
150                TypstAsLibError::TypstSource(diagnostics) => diagnostics
151                    .iter()
152                    .map(|d| d.message.as_str())
153                    .collect::<Vec<_>>()
154                    .join("\n"),
155                other => other.to_string(),
156            };
157            Error::Compilation(msg)
158        })?;
159
160        *self.lock_cache() = Some(compiled);
161
162        Ok(())
163    }
164
165    /// Compile if needed, then call `f` with a reference to the compiled document.
166    fn with_compiled<F, T>(&self, f: F) -> Result<T>
167    where
168        F: FnOnce(&PagedDocument) -> Result<T>,
169    {
170        self.compile_cached()?;
171        let cache = self.lock_cache();
172        let compiled = cache
173            .as_ref()
174            .expect("compiled_cache must be Some after successful compile_cached()");
175        f(compiled)
176    }
177
178    /// Compile the document and generate PDF.
179    ///
180    /// # Returns
181    /// PDF data as bytes.
182    ///
183    /// # Errors
184    /// Returns an error if compilation or PDF generation fails.
185    #[cfg(feature = "pdf")]
186    #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
187    pub fn to_pdf(&self) -> Result<Vec<u8>> {
188        self.with_compiled(|compiled| {
189            typst_pdf::pdf(compiled, &typst_pdf::PdfOptions::default())
190                .map_err(|e| Error::PdfGeneration(format!("{e:?}")))
191        })
192    }
193
194    /// Compile the document and generate SVG for each page.
195    ///
196    /// # Returns
197    /// A vector of SVG strings, one per page.
198    ///
199    /// # Errors
200    /// Returns an error if compilation fails.
201    #[cfg(feature = "svg")]
202    #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
203    pub fn to_svg(&self) -> Result<Vec<String>> {
204        self.with_compiled(|compiled| Ok(compiled.pages.iter().map(typst_svg::svg).collect()))
205    }
206
207    /// Compile the document and generate PNG for each page.
208    ///
209    /// # Arguments
210    /// * `dpi` - Resolution in dots per inch (e.g., 72 for 1:1, 144 for Retina, 300 for print)
211    ///
212    /// # Returns
213    /// A vector of PNG bytes, one per page.
214    ///
215    /// # Errors
216    /// Returns an error if compilation or PNG encoding fails.
217    #[cfg(feature = "png")]
218    #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
219    pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
220        self.with_compiled(|compiled| {
221            let pixel_per_pt = dpi / 72.0;
222            compiled
223                .pages
224                .iter()
225                .map(|page| {
226                    typst_render::render(page, pixel_per_pt)
227                        .encode_png()
228                        .map_err(|e| Error::PngEncoding(e.to_string()))
229                })
230                .collect()
231        })
232    }
233}
234
235/// Find a file in a `Dir` tree by a potentially nested path (e.g. "dir/main.typ").
236fn find_entry<'a>(dir: &'a Dir<'a>, path: &str) -> Option<&'a File<'a>> {
237    let normalized = path.trim_start_matches("./").replace('\\', "/");
238    let (dir_path, file_name) = match normalized.rsplit_once('/') {
239        Some((d, f)) => (Some(d), f),
240        None => (None, normalized.as_str()),
241    };
242
243    let target_dir = match dir_path {
244        Some(dir_path) => {
245            let mut current = dir;
246            for segment in dir_path.split('/') {
247                current = current
248                    .dirs()
249                    .find(|d| d.path().file_name().and_then(|n| n.to_str()) == Some(segment))?;
250            }
251            current
252        }
253        None => dir,
254    };
255
256    target_dir
257        .files()
258        .find(|f| f.path().file_name().and_then(|n| n.to_str()) == Some(file_name))
259}