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
116/// Render a text box to SVG markup.
117///
118/// Returns `None` if compilation fails.
119pub fn render_text_box_svg(
120    content: &str,
121    typst_prelude: &str,
122    px_width: u32,
123    px_height: u32,
124    font_size: f32,
125    color: [u8; 4],
126    background: Option<[u8; 4]>,
127) -> Option<String> {
128    let markup =
129        build_markup(content, typst_prelude, px_width, px_height, font_size, color, background);
130    if let Some(svg) = compile_markup_svg(&markup) {
131        return Some(svg);
132    }
133
134    let fallback_markup = build_plain_text_fallback(
135        content,
136        typst_prelude,
137        px_width,
138        px_height,
139        font_size,
140        color,
141        background,
142    );
143    let fallback = compile_markup_svg(&fallback_markup);
144    if fallback.is_some() {
145        warn!("Typst text box SVG render failed; fell back to plain-text rendering");
146    } else {
147        warn!("Typst text box SVG render failed, including plain-text fallback");
148    }
149    fallback
150}
151
152fn build_markup(
153    content: &str,
154    typst_prelude: &str,
155    px_width: u32,
156    px_height: u32,
157    font_size: f32,
158    color: [u8; 4],
159    background: Option<[u8; 4]>,
160) -> String {
161    let bg = match background {
162        Some([r, g, b, a]) => format!("rgb({r}, {g}, {b}, {a})"),
163        None => "rgb(0, 0, 0, 0)".to_string(),
164    };
165    let [r, g, b, a] = color;
166    let x_margin = (font_size * 0.2).clamp(1.0, 4.0);
167    let top_margin = (font_size * 0.35).max(2.0);
168    let prelude =
169        if typst_prelude.trim().is_empty() { String::new() } else { format!("{typst_prelude}\n") };
170    format!(
171        "#set page(width: {px_width}pt, height: {px_height}pt, margin: (x: {x_margin}pt, y: 4pt, top: {top_margin}pt), fill: {bg})\n\
172         #set text(size: {font_size}pt, fill: rgb({r}, {g}, {b}, {a}))\n\
173         {prelude}\
174         {content}"
175    )
176}
177
178fn build_plain_text_fallback(
179    content: &str,
180    typst_prelude: &str,
181    px_width: u32,
182    px_height: u32,
183    font_size: f32,
184    color: [u8; 4],
185    background: Option<[u8; 4]>,
186) -> String {
187    let escaped = escape_typst_string(content);
188    format!(
189        "{}\n#{}",
190        build_markup("", typst_prelude, px_width, px_height, font_size, color, background),
191        escaped
192    )
193}
194
195fn escape_typst_string(s: &str) -> String {
196    let mut escaped = String::with_capacity(s.len() + 2);
197    escaped.push('"');
198    for ch in s.chars() {
199        match ch {
200            '\\' => escaped.push_str("\\\\"),
201            '"' => escaped.push_str("\\\""),
202            '\n' => escaped.push_str("\\n"),
203            '\r' => escaped.push_str("\\r"),
204            '\t' => escaped.push_str("\\t"),
205            _ => escaped.push(ch),
206        }
207    }
208    escaped.push('"');
209    escaped
210}
211
212fn compile_markup(markup: &str) -> Option<RenderedTextBox> {
213    let world = MinimalWorld::new(markup.to_owned());
214    let result = typst::compile::<typst_library::layout::PagedDocument>(&world);
215    let document = result.output.ok()?;
216    let page = document.pages.into_iter().next()?;
217
218    // pixel_per_pt = 1.0 because we set page dimensions in pt equal to px_width/px_height
219    let pixmap = typst_render::render(&page, 1.0);
220
221    // tiny-skia outputs premultiplied RGBA; convert to straight for egui.
222    let src = pixmap.data();
223    let mut rgba = Vec::with_capacity(src.len());
224    for chunk in src.chunks_exact(4) {
225        let [r, g, b, a] = [chunk[0], chunk[1], chunk[2], chunk[3]];
226        if a == 0 {
227            rgba.extend_from_slice(&[0, 0, 0, 0]);
228        } else {
229            rgba.push(unpremultiply_channel(r, a));
230            rgba.push(unpremultiply_channel(g, a));
231            rgba.push(unpremultiply_channel(b, a));
232            rgba.push(a);
233        }
234    }
235
236    Some(RenderedTextBox { data: rgba, width: pixmap.width(), height: pixmap.height() })
237}
238
239fn compile_markup_svg(markup: &str) -> Option<String> {
240    let world = MinimalWorld::new(markup.to_owned());
241    let result = typst::compile::<typst_library::layout::PagedDocument>(&world);
242    let document = result.output.ok()?;
243    let page = document.pages.into_iter().next()?;
244    Some(typst_svg::svg(&page))
245}
246
247fn unpremultiply_channel(channel: u8, alpha: u8) -> u8 {
248    let numerator = u16::from(channel) * 255 + (u16::from(alpha) / 2);
249    let value = numerator / u16::from(alpha);
250    u8::try_from(value).expect("unpremultiplied channel is always in u8 range")
251}
252
253/// Cache key for rendered text boxes.
254#[derive(PartialEq, Eq, Hash, Clone)]
255struct CacheKey {
256    content_hash: u64,
257    prelude_hash: u64,
258    width: u32,
259    height: u32,
260    font_size_bits: u32,
261    color: [u8; 4],
262    background: Option<[u8; 4]>,
263}
264
265fn hash_str(s: &str) -> u64 {
266    let mut h = DefaultHasher::new();
267    s.hash(&mut h);
268    h.finish()
269}
270
271/// Cache mapping (content, size, style) → rendered bitmap.
272pub struct TextBoxRenderCache {
273    entries: HashMap<CacheKey, RenderedTextBox>,
274}
275
276/// Inputs for rendering and caching one text box.
277#[derive(Clone, Copy)]
278pub struct TextBoxRenderRequest<'a> {
279    pub content: &'a str,
280    pub typst_prelude: &'a str,
281    pub px_width: u32,
282    pub px_height: u32,
283    pub font_size: f32,
284    pub color: [u8; 4],
285    pub background: Option<[u8; 4]>,
286}
287
288impl Default for TextBoxRenderCache {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294impl TextBoxRenderCache {
295    pub fn new() -> Self {
296        Self { entries: HashMap::new() }
297    }
298
299    /// Get or render a text box bitmap.
300    pub fn get_or_render(&mut self, request: TextBoxRenderRequest<'_>) -> Option<&RenderedTextBox> {
301        let key = CacheKey {
302            content_hash: hash_str(request.content),
303            prelude_hash: hash_str(request.typst_prelude),
304            width: request.px_width,
305            height: request.px_height,
306            font_size_bits: request.font_size.to_bits(),
307            color: request.color,
308            background: request.background,
309        };
310        if !self.entries.contains_key(&key)
311            && let Some(rendered) = render_text_box(
312                request.content,
313                request.typst_prelude,
314                request.px_width,
315                request.px_height,
316                request.font_size,
317                request.color,
318                request.background,
319            )
320        {
321            self.entries.insert(key.clone(), rendered);
322        }
323        self.entries.get(&key)
324    }
325
326    /// Remove all cached entries for a given content string (call after edit).
327    pub fn invalidate(&mut self, content: &str) {
328        let h = hash_str(content);
329        self.entries.retain(|k, _| k.content_hash != h);
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::{render_text_box, render_text_box_svg};
336
337    fn top_row_has_visible_pixels(rendered: &super::RenderedTextBox) -> bool {
338        rendered.data[..rendered.width as usize * 4].chunks_exact(4).any(|pixel| pixel[3] > 0)
339    }
340
341    fn first_visible_pixel(rendered: &super::RenderedTextBox) -> Option<[u8; 4]> {
342        rendered
343            .data
344            .chunks_exact(4)
345            .find(|pixel| pixel[3] > 0)
346            .map(|pixel| <[u8; 4]>::try_from(pixel).expect("chunk size is exactly four bytes"))
347    }
348
349    #[test]
350    fn renders_valid_typst_markup() {
351        let rendered =
352            render_text_box("Hello, *Typst*!", "", 240, 80, 20.0, [255, 255, 255, 255], None);
353        assert!(rendered.is_some());
354    }
355
356    #[test]
357    fn renders_valid_typst_markup_to_svg() {
358        let svg =
359            render_text_box_svg("Hello, *Typst*!", "", 240, 80, 20.0, [255, 255, 255, 255], None)
360                .expect("valid typst should render to SVG");
361
362        assert!(svg.starts_with("<svg"));
363    }
364
365    #[test]
366    fn falls_back_for_invalid_typst_markup() {
367        let rendered = render_text_box("#let x = ", "", 240, 80, 20.0, [255, 255, 255, 255], None)
368            .expect("fallback should still produce a bitmap");
369        assert_eq!(rendered.width, 240);
370        assert_eq!(rendered.height, 80);
371        assert_eq!(rendered.data.len(), 240 * 80 * 4);
372    }
373
374    #[test]
375    fn transparent_background_renders_without_white_matte() {
376        let rendered = render_text_box("$pi r^2$", "", 240, 80, 24.0, [0, 0, 0, 255], None)
377            .expect("formula should render");
378
379        assert_eq!(&rendered.data[..4], &[0, 0, 0, 0]);
380    }
381
382    #[test]
383    fn first_line_superscript_has_top_padding() {
384        let rendered = render_text_box("$pi r^2$", "", 240, 80, 24.0, [0, 0, 0, 255], None)
385            .expect("formula should render");
386
387        assert!(!top_row_has_visible_pixels(&rendered));
388    }
389
390    #[test]
391    fn typst_prelude_can_override_default_text_settings() {
392        let rendered = render_text_box(
393            "Override",
394            "#set text(fill: rgb(255, 0, 0))",
395            240,
396            80,
397            20.0,
398            [0, 0, 0, 255],
399            None,
400        )
401        .expect("prelude should render");
402
403        let pixel = first_visible_pixel(&rendered).expect("text should have visible pixels");
404        assert!(pixel[0] > pixel[1]);
405        assert!(pixel[0] > pixel[2]);
406    }
407}