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 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 let pixmap = typst_render::render(&page, 1.0);
164
165 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#[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
206pub 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 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 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}