Skip to main content

secunit_core/wisp/render/
typst.rs

1//! Typst backend — compiles the composed `main.typ` to PDF in-process using
2//! the `typst` + `typst-pdf` crates (the default WISP renderer; pure Rust, no
3//! external toolchain).
4//!
5//! The operator's template directory is the import root: `#import "theme.typ"`
6//! and `image("logo.svg")` in the partials resolve to files there. The body
7//! fonts (Inter + JetBrains Mono) are bundled into the binary and loaded into
8//! the Typst font book so output is identical on every machine.
9//!
10//! With `--no-default-features` (no `pdf` feature) the heavy typst dependency
11//! is dropped and this backend just writes the composed `.typ` beside the
12//! intended output.
13
14use std::fs;
15
16use anyhow::{Context, Result};
17
18use super::{RenderRequest, RenderResult};
19
20#[cfg(not(feature = "pdf"))]
21pub fn render(req: &RenderRequest) -> Result<RenderResult> {
22    let typ_path = req.output.with_extension("typ");
23    if let Some(parent) = typ_path.parent() {
24        fs::create_dir_all(parent)
25            .with_context(|| format!("create output dir {}", parent.display()))?;
26    }
27    fs::write(&typ_path, req.typst_source)
28        .with_context(|| format!("write {}", typ_path.display()))?;
29    tracing::warn!(
30        "wisp export: built without the `pdf` feature — wrote Typst source to {} \
31         (no PDF compiled).",
32        typ_path.display()
33    );
34    Ok(RenderResult {
35        pages: None,
36        wrote_pdf: false,
37        intermediate: Some(typ_path),
38    })
39}
40
41#[cfg(feature = "pdf")]
42pub fn render(req: &RenderRequest) -> Result<RenderResult> {
43    use anyhow::{anyhow, bail};
44
45    if let Some(parent) = req.output.parent() {
46        fs::create_dir_all(parent)
47            .with_context(|| format!("create output dir {}", parent.display()))?;
48    }
49    // Keep the composed source beside the PDF for debugging/inspection.
50    let typ_path = req.output.with_extension("typ");
51    fs::write(&typ_path, req.typst_source)
52        .with_context(|| format!("write {}", typ_path.display()))?;
53
54    let world = world::WispWorld::new(req.template_dir, req.typst_source)?;
55
56    let typst::diag::Warned { output, warnings } =
57        typst::compile::<typst::layout::PagedDocument>(&world);
58    for w in &warnings {
59        tracing::warn!("typst: {}", w.message);
60    }
61    let document = output.map_err(|errs| {
62        let detail = errs
63            .iter()
64            .map(|e| e.message.to_string())
65            .collect::<Vec<_>>()
66            .join("; ");
67        anyhow!("typst compile failed: {detail}")
68    })?;
69
70    let pages = document.pages.len() as u32;
71
72    let pdf = typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default())
73        .map_err(|errs| anyhow!("typst PDF export failed: {} diagnostic(s)", errs.len()))?;
74    if pdf.is_empty() {
75        bail!("typst produced an empty PDF");
76    }
77    fs::write(req.output, &pdf).with_context(|| format!("write {}", req.output.display()))?;
78
79    Ok(RenderResult {
80        pages: Some(pages),
81        wrote_pdf: true,
82        intermediate: Some(typ_path),
83    })
84}
85
86#[cfg(feature = "pdf")]
87mod world {
88    use std::collections::HashMap;
89    use std::path::{Path, PathBuf};
90    use std::sync::Mutex;
91
92    use anyhow::{Context, Result};
93    use typst::diag::{FileError, FileResult};
94    use typst::foundations::{Bytes, Datetime};
95    use typst::syntax::{FileId, Source, VirtualPath};
96    use typst::text::{Font, FontBook};
97    use typst::utils::LazyHash;
98    use typst::{Library, World};
99
100    /// Bundled body + mono fonts (see assets/fonts/README.md). Loaded into the
101    /// Typst font book so output never depends on system fonts.
102    const FONTS: &[&[u8]] = &[
103        include_bytes!("../assets/fonts/Inter-Regular.ttf"),
104        include_bytes!("../assets/fonts/Inter-Medium.ttf"),
105        include_bytes!("../assets/fonts/Inter-SemiBold.ttf"),
106        include_bytes!("../assets/fonts/Inter-Bold.ttf"),
107        include_bytes!("../assets/fonts/Inter-Italic.ttf"),
108        include_bytes!("../assets/fonts/JetBrainsMono-Regular.ttf"),
109        include_bytes!("../assets/fonts/JetBrainsMono-Medium.ttf"),
110    ];
111
112    pub struct WispWorld {
113        library: LazyHash<Library>,
114        book: LazyHash<FontBook>,
115        fonts: Vec<Font>,
116        root: PathBuf,
117        main: FileId,
118        main_source: Source,
119        /// Cache of source/byte reads keyed by file id.
120        slots: Mutex<HashMap<FileId, FileResult<Bytes>>>,
121    }
122
123    impl WispWorld {
124        pub fn new(template_dir: &Path, main_src: &str) -> Result<Self> {
125            let root = template_dir
126                .canonicalize()
127                .with_context(|| format!("resolve template dir {}", template_dir.display()))?;
128
129            let mut fonts = Vec::new();
130            let mut book = FontBook::new();
131            for raw in FONTS {
132                let bytes = Bytes::new(*raw);
133                // A TTF holds one face; index 0.
134                if let Some(font) = Font::new(bytes, 0) {
135                    book.push(font.info().clone());
136                    fonts.push(font);
137                }
138            }
139
140            let main = FileId::new(None, VirtualPath::new("main.typ"));
141            let main_source = Source::new(main, main_src.to_string());
142
143            Ok(Self {
144                library: LazyHash::new(Library::default()),
145                book: LazyHash::new(book),
146                fonts,
147                root,
148                main,
149                main_source,
150                slots: Mutex::new(HashMap::new()),
151            })
152        }
153
154        /// Resolve a non-main file id to an on-disk path under the template dir.
155        fn path_of(&self, id: FileId) -> FileResult<PathBuf> {
156            // Only package-less ids (our template) are supported.
157            if id.package().is_some() {
158                return Err(FileError::NotFound(id.vpath().as_rootless_path().into()));
159            }
160            id.vpath()
161                .resolve(&self.root)
162                .ok_or_else(|| FileError::AccessDenied)
163        }
164
165        fn read_bytes(&self, id: FileId) -> FileResult<Bytes> {
166            if let Some(hit) = self.slots.lock().unwrap().get(&id) {
167                return hit.clone();
168            }
169            let result = (|| {
170                let path = self.path_of(id)?;
171                let data = std::fs::read(&path).map_err(|e| FileError::from_io(e, &path))?;
172                Ok(Bytes::new(data))
173            })();
174            self.slots.lock().unwrap().insert(id, result.clone());
175            result
176        }
177    }
178
179    impl World for WispWorld {
180        fn library(&self) -> &LazyHash<Library> {
181            &self.library
182        }
183
184        fn book(&self) -> &LazyHash<FontBook> {
185            &self.book
186        }
187
188        fn main(&self) -> FileId {
189            self.main
190        }
191
192        fn source(&self, id: FileId) -> FileResult<Source> {
193            if id == self.main {
194                return Ok(self.main_source.clone());
195            }
196            // Read partials as text straight from disk (Bytes→str conversion
197            // differs across typst patch versions; this avoids it).
198            let path = self.path_of(id)?;
199            let text = std::fs::read_to_string(&path).map_err(|e| FileError::from_io(e, &path))?;
200            Ok(Source::new(id, text))
201        }
202
203        fn file(&self, id: FileId) -> FileResult<Bytes> {
204            self.read_bytes(id)
205        }
206
207        fn font(&self, index: usize) -> Option<Font> {
208            self.fonts.get(index).cloned()
209        }
210
211        fn today(&self, _offset: Option<i64>) -> Option<Datetime> {
212            // The partials don't call datetime.today(); keep output deterministic.
213            None
214        }
215    }
216}