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
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 let pixmap = typst_render::render(&page, 1.0);
184
185 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#[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
227pub struct TextBoxRenderCache {
229 entries: HashMap<CacheKey, RenderedTextBox>,
230}
231
232#[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 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 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}