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    px_width: u32,
86    px_height: u32,
87    font_size: f32,
88    color: [u8; 4],
89    background: Option<[u8; 4]>,
90) -> Option<RenderedTextBox> {
91    let markup = build_markup(content, px_width, px_height, font_size, color, background);
92    if let Some(rendered) = compile_markup(&markup) {
93        return Some(rendered);
94    }
95
96    let fallback_markup =
97        build_plain_text_fallback(content, px_width, px_height, font_size, color, background);
98    let fallback = compile_markup(&fallback_markup);
99    if fallback.is_some() {
100        warn!("Typst text box render failed; fell back to plain-text rendering");
101    } else {
102        warn!("Typst text box render failed, including plain-text fallback");
103    }
104    fallback
105}
106
107fn build_markup(
108    content: &str,
109    px_width: u32,
110    px_height: u32,
111    font_size: f32,
112    color: [u8; 4],
113    background: Option<[u8; 4]>,
114) -> String {
115    let bg = match background {
116        Some([r, g, b, a]) => format!("rgb({r}, {g}, {b}, {a})"),
117        None => "none".to_string(),
118    };
119    let [r, g, b, a] = color;
120    format!(
121        "#set page(width: {px_width}pt, height: {px_height}pt, margin: 4pt, fill: {bg})\n\
122         #set text(size: {font_size}pt, fill: rgb({r}, {g}, {b}, {a}))\n\
123         {content}"
124    )
125}
126
127fn build_plain_text_fallback(
128    content: &str,
129    px_width: u32,
130    px_height: u32,
131    font_size: f32,
132    color: [u8; 4],
133    background: Option<[u8; 4]>,
134) -> String {
135    let escaped = escape_typst_string(content);
136    format!("{}\n#{}", build_markup("", px_width, px_height, font_size, color, background), escaped)
137}
138
139fn escape_typst_string(s: &str) -> String {
140    let mut escaped = String::with_capacity(s.len() + 2);
141    escaped.push('"');
142    for ch in s.chars() {
143        match ch {
144            '\\' => escaped.push_str("\\\\"),
145            '"' => escaped.push_str("\\\""),
146            '\n' => escaped.push_str("\\n"),
147            '\r' => escaped.push_str("\\r"),
148            '\t' => escaped.push_str("\\t"),
149            _ => escaped.push(ch),
150        }
151    }
152    escaped.push('"');
153    escaped
154}
155
156fn compile_markup(markup: &str) -> Option<RenderedTextBox> {
157    let world = MinimalWorld::new(markup.to_owned());
158    let result = typst::compile::<typst_library::layout::PagedDocument>(&world);
159    let document = result.output.ok()?;
160    let page = document.pages.into_iter().next()?;
161
162    // pixel_per_pt = 1.0 because we set page dimensions in pt equal to px_width/px_height
163    let pixmap = typst_render::render(&page, 1.0);
164
165    // tiny-skia outputs premultiplied RGBA; convert to straight for egui.
166    let src = pixmap.data();
167    let mut rgba = Vec::with_capacity(src.len());
168    for chunk in src.chunks_exact(4) {
169        let [r, g, b, a] = [chunk[0], chunk[1], chunk[2], chunk[3]];
170        if a == 0 {
171            rgba.extend_from_slice(&[0, 0, 0, 0]);
172        } else {
173            rgba.push(unpremultiply_channel(r, a));
174            rgba.push(unpremultiply_channel(g, a));
175            rgba.push(unpremultiply_channel(b, a));
176            rgba.push(a);
177        }
178    }
179
180    Some(RenderedTextBox { data: rgba, width: pixmap.width(), height: pixmap.height() })
181}
182
183fn unpremultiply_channel(channel: u8, alpha: u8) -> u8 {
184    let numerator = u16::from(channel) * 255 + (u16::from(alpha) / 2);
185    let value = numerator / u16::from(alpha);
186    u8::try_from(value).expect("unpremultiplied channel is always in u8 range")
187}
188
189/// Cache key for rendered text boxes.
190#[derive(PartialEq, Eq, Hash, Clone)]
191struct CacheKey {
192    content_hash: u64,
193    width: u32,
194    height: u32,
195    font_size_bits: u32,
196    color: [u8; 4],
197    background: Option<[u8; 4]>,
198}
199
200fn hash_str(s: &str) -> u64 {
201    let mut h = DefaultHasher::new();
202    s.hash(&mut h);
203    h.finish()
204}
205
206/// Cache mapping (content, size, style) → rendered bitmap.
207pub struct TextBoxRenderCache {
208    entries: HashMap<CacheKey, RenderedTextBox>,
209}
210
211impl Default for TextBoxRenderCache {
212    fn default() -> Self {
213        Self::new()
214    }
215}
216
217impl TextBoxRenderCache {
218    pub fn new() -> Self {
219        Self { entries: HashMap::new() }
220    }
221
222    /// Get or render a text box bitmap.
223    pub fn get_or_render(
224        &mut self,
225        content: &str,
226        px_width: u32,
227        px_height: u32,
228        font_size: f32,
229        color: [u8; 4],
230        background: Option<[u8; 4]>,
231    ) -> Option<&RenderedTextBox> {
232        let key = CacheKey {
233            content_hash: hash_str(content),
234            width: px_width,
235            height: px_height,
236            font_size_bits: font_size.to_bits(),
237            color,
238            background,
239        };
240        if !self.entries.contains_key(&key)
241            && let Some(rendered) =
242                render_text_box(content, px_width, px_height, font_size, color, background)
243        {
244            self.entries.insert(key.clone(), rendered);
245        }
246        self.entries.get(&key)
247    }
248
249    /// Remove all cached entries for a given content string (call after edit).
250    pub fn invalidate(&mut self, content: &str) {
251        let h = hash_str(content);
252        self.entries.retain(|k, _| k.content_hash != h);
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::render_text_box;
259
260    #[test]
261    fn renders_valid_typst_markup() {
262        let rendered =
263            render_text_box("Hello, *Typst*!", 240, 80, 20.0, [255, 255, 255, 255], None);
264        assert!(rendered.is_some());
265    }
266
267    #[test]
268    fn falls_back_for_invalid_typst_markup() {
269        let rendered = render_text_box("#let x = ", 240, 80, 20.0, [255, 255, 255, 255], None)
270            .expect("fallback should still produce a bitmap");
271        assert_eq!(rendered.width, 240);
272        assert_eq!(rendered.height, 80);
273        assert_eq!(rendered.data.len(), 240 * 80 * 4);
274    }
275}