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
15pub struct RenderedTextBox {
17 pub data: Vec<u8>,
18 pub width: u32,
19 pub height: u32,
20}
21
22static 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
80pub 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
116pub 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 let pixmap = typst_render::render(&page, 1.0);
220
221 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#[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
271pub struct TextBoxRenderCache {
273 entries: HashMap<CacheKey, RenderedTextBox>,
274}
275
276#[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 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 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}