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;
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 = self
116            .templates
117            .get_file(self.entry)
118            .ok_or(Error::EntryNotFound(self.entry))?;
119
120        let main_bytes = decompress(main_file.contents())?;
121        let main_content = std::str::from_utf8(&main_bytes).map_err(|_| Error::InvalidUtf8)?;
122
123        let resolver = EmbeddedResolver::new(self.templates, self.packages);
124
125        // Collect and decompress fonts from the embedded fonts directory
126        let font_data: Vec<Vec<u8>> = self
127            .fonts
128            .files()
129            .map(|f| decompress(f.contents()).map_err(Error::from))
130            .collect::<Result<Vec<_>>>()?;
131
132        let font_refs: Vec<&[u8]> = font_data.iter().map(Vec::as_slice).collect();
133
134        let engine = TypstEngine::builder()
135            .main_file(main_content)
136            .add_file_resolver(resolver)
137            .fonts(font_refs)
138            .build();
139
140        // Clone inputs (preserve for retry on failure)
141        let inputs = self.lock_inputs().clone();
142
143        let warned_result = if let Some(inputs) = inputs {
144            engine.compile_with_input::<_, PagedDocument>(inputs)
145        } else {
146            engine.compile::<PagedDocument>()
147        };
148
149        // Handle the Warned wrapper and extract result
150        let compiled = warned_result.output.map_err(|e| {
151            let msg = match e {
152                TypstAsLibError::TypstSource(diagnostics) => diagnostics
153                    .iter()
154                    .map(|d| d.message.as_str())
155                    .collect::<Vec<_>>()
156                    .join("\n"),
157                other => other.to_string(),
158            };
159            Error::Compilation(msg)
160        })?;
161
162        *self.lock_cache() = Some(compiled);
163
164        Ok(())
165    }
166
167    /// Compile if needed, then call `f` with a reference to the compiled document.
168    fn with_compiled<F, T>(&self, f: F) -> Result<T>
169    where
170        F: FnOnce(&PagedDocument) -> Result<T>,
171    {
172        self.compile_cached()?;
173        let cache = self.lock_cache();
174        let compiled = cache
175            .as_ref()
176            .expect("compiled_cache must be Some after successful compile_cached()");
177        f(compiled)
178    }
179
180    /// Compile the document and generate PDF.
181    ///
182    /// # Returns
183    /// PDF data as bytes.
184    ///
185    /// # Errors
186    /// Returns an error if compilation or PDF generation fails.
187    #[cfg(feature = "pdf")]
188    #[cfg_attr(docsrs, doc(cfg(feature = "pdf")))]
189    pub fn to_pdf(&self) -> Result<Vec<u8>> {
190        self.with_compiled(|compiled| {
191            typst_pdf::pdf(compiled, &typst_pdf::PdfOptions::default())
192                .map_err(|e| Error::PdfGeneration(format!("{e:?}")))
193        })
194    }
195
196    /// Compile the document and generate SVG for each page.
197    ///
198    /// # Returns
199    /// A vector of SVG strings, one per page.
200    ///
201    /// # Errors
202    /// Returns an error if compilation fails.
203    #[cfg(feature = "svg")]
204    #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
205    pub fn to_svg(&self) -> Result<Vec<String>> {
206        self.with_compiled(|compiled| Ok(compiled.pages.iter().map(typst_svg::svg).collect()))
207    }
208
209    /// Compile the document and generate PNG for each page.
210    ///
211    /// # Arguments
212    /// * `dpi` - Resolution in dots per inch (e.g., 72 for 1:1, 144 for Retina, 300 for print)
213    ///
214    /// # Returns
215    /// A vector of PNG bytes, one per page.
216    ///
217    /// # Errors
218    /// Returns an error if compilation or PNG encoding fails.
219    #[cfg(feature = "png")]
220    #[cfg_attr(docsrs, doc(cfg(feature = "png")))]
221    pub fn to_png(&self, dpi: f32) -> Result<Vec<Vec<u8>>> {
222        self.with_compiled(|compiled| {
223            let pixel_per_pt = dpi / 72.0;
224            compiled
225                .pages
226                .iter()
227                .map(|page| {
228                    typst_render::render(page, pixel_per_pt)
229                        .encode_png()
230                        .map_err(|e| Error::PngEncoding(e.to_string()))
231                })
232                .collect()
233        })
234    }
235}