Skip to main content

dais_document/
typst_renderer.rs

1use std::collections::HashMap;
2use std::hash::{DefaultHasher, Hash, Hasher};
3use std::sync::LazyLock;
4
5use tracing::warn;
6use typst::Library;
7use typst::LibraryExt;
8use typst::diag::{FileError, FileResult};
9use typst::foundations::{Bytes, Datetime};
10use typst::syntax::{FileId, Source, VirtualPath};
11use typst::text::{Font, FontBook};
12use typst::utils::LazyHash;
13use typst_kit::fonts::{FontSearcher, Fonts};
14
15/// RGBA bitmap output from a Typst render.
16pub struct RenderedTextBox {
17    pub data: Vec<u8>,
18    pub width: u32,
19    pub height: u32,
20}
21
22// Load system fonts once; font loading is expensive.
23static FONTS: LazyLock<Fonts> = LazyLock::new(|| FontSearcher::new().search());
24
25struct MinimalWorld {
26    library: LazyHash<Library>,
27    book: LazyHash<FontBook>,
28    source: Source,
29    main_id: FileId,
30}
31
32impl MinimalWorld {
33    fn new(markup: String) -> Self {
34        let main_id = FileId::new(None, VirtualPath::new("main.typ"));
35        let source = Source::new(main_id, markup);
36        let fonts = &*FONTS;
37        Self {
38            library: LazyHash::new(Library::default()),
39            book: LazyHash::new(fonts.book.clone()),
40            source,
41            main_id,
42        }
43    }
44}
45
46impl typst_library::World for MinimalWorld {
47    fn library(&self) -> &LazyHash<Library> {
48        &self.library
49    }
50
51    fn book(&self) -> &LazyHash<FontBook> {
52        &self.book
53    }
54
55    fn main(&self) -> FileId {
56        self.main_id
57    }
58
59    fn source(&self, id: FileId) -> FileResult<Source> {
60        if id == self.main_id {
61            Ok(self.source.clone())
62        } else {
63            Err(FileError::NotFound(id.vpath().as_rootless_path().into()))
64        }
65    }
66
67    fn file(&self, id: FileId) -> FileResult<Bytes> {
68        Err(FileError::NotFound(id.vpath().as_rootless_path().into()))
69    }
70
71    fn font(&self, index: usize) -> Option<Font> {
72        FONTS.fonts.get(index)?.get()
73    }
74
75    fn today(&self, _offset: Option<i64>) -> Option<Datetime> {
76        None
77    }
78}
79
80/// Render a text box to an RGBA bitmap.
81///
82/// Returns `None` if compilation or rasterization fails.
83pub fn render_text_box(
84    content: &str,
85    typst_prelude: &str,
86    px_width: u32,
87    px_height: u32,
88    font_size: f32,
89    color: [u8; 4],
90    background: Option<[u8; 4]>,
91) -> Option<RenderedTextBox> {
92    let markup =
93        build_markup(content, typst_prelude, px_width, px_height, font_size, color, background);
94    if let Some(rendered) = compile_markup(&markup) {
95        return Some(rendered);
96    }
97
98    let fallback_markup = build_plain_text_fallback(
99        content,
100        typst_prelude,
101        px_width,
102        px_height,
103        font_size,
104        color,
105        background,
106    );
107    let fallback = compile_markup(&fallback_markup);
108    if fallback.is_some() {
109        warn!("Typst text box render failed; fell back to plain-text rendering");
110    } else {
111        warn!("Typst text box render failed, including plain-text fallback");
112    }
113    fallback
114}
115
116fn build_markup(
117    content: &str,
118    typst_prelude: &str,
119    px_width: u32,
120    px_height: u32,
121    font_size: f32,
122    color: [u8; 4],
123    background: Option<[u8; 4]>,
124) -> String {
125    let bg = match background {
126        Some([r, g, b, a]) => format!("rgb({r}, {g}, {b}, {a})"),
127        None => "rgb(0, 0, 0, 0)".to_string(),
128    };
129    let [r, g, b, a] = color;
130    let x_margin = (font_size * 0.2).clamp(1.0, 4.0);
131    let top_margin = (font_size * 0.35).max(2.0);
132    let prelude =
133        if typst_prelude.trim().is_empty() { String::new() } else { format!("{typst_prelude}\n") };
134    format!(
135        "#set page(width: {px_width}pt, height: {px_height}pt, margin: (x: {x_margin}pt, y: 4pt, top: {top_margin}pt), fill: {bg})\n\
136         #set text(size: {font_size}pt, fill: rgb({r}, {g}, {b}, {a}))\n\
137         {prelude}\
138         {content}"
139    )
140}
141
142fn build_plain_text_fallback(
143    content: &str,
144    typst_prelude: &str,
145    px_width: u32,
146    px_height: u32,
147    font_size: f32,
148    color: [u8; 4],
149    background: Option<[u8; 4]>,
150) -> String {
151    let escaped = escape_typst_string(content);
152    format!(
153        "{}\n#{}",
154        build_markup("", typst_prelude, px_width, px_height, font_size, color, background),
155        escaped
156    )
157}
158
159fn escape_typst_string(s: &str) -> String {
160    let mut escaped = String::with_capacity(s.len() + 2);
161    escaped.push('"');
162    for ch in s.chars() {
163        match ch {
164            '\\' => escaped.push_str("\\\\"),
165            '"' => escaped.push_str("\\\""),
166            '\n' => escaped.push_str("\\n"),
167            '\r' => escaped.push_str("\\r"),
168            '\t' => escaped.push_str("\\t"),
169            _ => escaped.push(ch),
170        }
171    }
172    escaped.push('"');
173    escaped
174}
175
176fn compile_markup(markup: &str) -> Option<RenderedTextBox> {
177    let world = MinimalWorld::new(markup.to_owned());
178    let result = typst::compile::<typst_library::layout::PagedDocument>(&world);
179    let document = result.output.ok()?;
180    let page = document.pages.into_iter().next()?;
181
182    // pixel_per_pt = 1.0 because we set page dimensions in pt equal to px_width/px_height
183    let pixmap = typst_render::render(&page, 1.0);
184
185    // tiny-skia outputs premultiplied RGBA; convert to straight for egui.
186    let src = pixmap.data();
187    let mut rgba = Vec::with_capacity(src.len());
188    for chunk in src.chunks_exact(4) {
189        let [r, g, b, a] = [chunk[0], chunk[1], chunk[2], chunk[3]];
190        if a == 0 {
191            rgba.extend_from_slice(&[0, 0, 0, 0]);
192        } else {
193            rgba.push(unpremultiply_channel(r, a));
194            rgba.push(unpremultiply_channel(g, a));
195            rgba.push(unpremultiply_channel(b, a));
196            rgba.push(a);
197        }
198    }
199
200    Some(RenderedTextBox { data: rgba, width: pixmap.width(), height: pixmap.height() })
201}
202
203fn unpremultiply_channel(channel: u8, alpha: u8) -> u8 {
204    let numerator = u16::from(channel) * 255 + (u16::from(alpha) / 2);
205    let value = numerator / u16::from(alpha);
206    u8::try_from(value).expect("unpremultiplied channel is always in u8 range")
207}
208
209/// Cache key for rendered text boxes.
210#[derive(PartialEq, Eq, Hash, Clone)]
211struct CacheKey {
212    content_hash: u64,
213    prelude_hash: u64,
214    width: u32,
215    height: u32,
216    font_size_bits: u32,
217    color: [u8; 4],
218    background: Option<[u8; 4]>,
219}
220
221fn hash_str(s: &str) -> u64 {
222    let mut h = DefaultHasher::new();
223    s.hash(&mut h);
224    h.finish()
225}
226
227/// Cache mapping (content, size, style) → rendered bitmap.
228pub struct TextBoxRenderCache {
229    entries: HashMap<CacheKey, RenderedTextBox>,
230}
231
232/// Inputs for rendering and caching one text box.
233#[derive(Clone, Copy)]
234pub struct TextBoxRenderRequest<'a> {
235    pub content: &'a str,
236    pub typst_prelude: &'a str,
237    pub px_width: u32,
238    pub px_height: u32,
239    pub font_size: f32,
240    pub color: [u8; 4],
241    pub background: Option<[u8; 4]>,
242}
243
244impl Default for TextBoxRenderCache {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250impl TextBoxRenderCache {
251    pub fn new() -> Self {
252        Self { entries: HashMap::new() }
253    }
254
255    /// Get or render a text box bitmap.
256    pub fn get_or_render(&mut self, request: TextBoxRenderRequest<'_>) -> Option<&RenderedTextBox> {
257        let key = CacheKey {
258            content_hash: hash_str(request.content),
259            prelude_hash: hash_str(request.typst_prelude),
260            width: request.px_width,
261            height: request.px_height,
262            font_size_bits: request.font_size.to_bits(),
263            color: request.color,
264            background: request.background,
265        };
266        if !self.entries.contains_key(&key)
267            && let Some(rendered) = render_text_box(
268                request.content,
269                request.typst_prelude,
270                request.px_width,
271                request.px_height,
272                request.font_size,
273                request.color,
274                request.background,
275            )
276        {
277            self.entries.insert(key.clone(), rendered);
278        }
279        self.entries.get(&key)
280    }
281
282    /// Remove all cached entries for a given content string (call after edit).
283    pub fn invalidate(&mut self, content: &str) {
284        let h = hash_str(content);
285        self.entries.retain(|k, _| k.content_hash != h);
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::render_text_box;
292
293    fn top_row_has_visible_pixels(rendered: &super::RenderedTextBox) -> bool {
294        rendered.data[..rendered.width as usize * 4].chunks_exact(4).any(|pixel| pixel[3] > 0)
295    }
296
297    fn first_visible_pixel(rendered: &super::RenderedTextBox) -> Option<[u8; 4]> {
298        rendered
299            .data
300            .chunks_exact(4)
301            .find(|pixel| pixel[3] > 0)
302            .map(|pixel| <[u8; 4]>::try_from(pixel).expect("chunk size is exactly four bytes"))
303    }
304
305    #[test]
306    fn renders_valid_typst_markup() {
307        let rendered =
308            render_text_box("Hello, *Typst*!", "", 240, 80, 20.0, [255, 255, 255, 255], None);
309        assert!(rendered.is_some());
310    }
311
312    #[test]
313    fn falls_back_for_invalid_typst_markup() {
314        let rendered = render_text_box("#let x = ", "", 240, 80, 20.0, [255, 255, 255, 255], None)
315            .expect("fallback should still produce a bitmap");
316        assert_eq!(rendered.width, 240);
317        assert_eq!(rendered.height, 80);
318        assert_eq!(rendered.data.len(), 240 * 80 * 4);
319    }
320
321    #[test]
322    fn transparent_background_renders_without_white_matte() {
323        let rendered = render_text_box("$pi r^2$", "", 240, 80, 24.0, [0, 0, 0, 255], None)
324            .expect("formula should render");
325
326        assert_eq!(&rendered.data[..4], &[0, 0, 0, 0]);
327    }
328
329    #[test]
330    fn first_line_superscript_has_top_padding() {
331        let rendered = render_text_box("$pi r^2$", "", 240, 80, 24.0, [0, 0, 0, 255], None)
332            .expect("formula should render");
333
334        assert!(!top_row_has_visible_pixels(&rendered));
335    }
336
337    #[test]
338    fn typst_prelude_can_override_default_text_settings() {
339        let rendered = render_text_box(
340            "Override",
341            "#set text(fill: rgb(255, 0, 0))",
342            240,
343            80,
344            20.0,
345            [0, 0, 0, 255],
346            None,
347        )
348        .expect("prelude should render");
349
350        let pixel = first_visible_pixel(&rendered).expect("text should have visible pixels");
351        assert!(pixel[0] > pixel[1]);
352        assert!(pixel[0] > pixel[2]);
353    }
354}