Skip to repository content459 lines · 15.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:48:01.786Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
svg_renderer.rs
1use crate::{
2 AssetSource, DevicePixels, IsZero, RenderImage, Result, SharedString, Size,
3 swap_rgba_pa_to_bgra,
4};
5use image::Frame;
6use resvg::tiny_skia::Pixmap;
7use smallvec::SmallVec;
8use std::{
9 hash::Hash,
10 sync::{Arc, LazyLock, OnceLock},
11};
12
13#[cfg(target_os = "macos")]
14const EMOJI_FONT_FAMILIES: &[&str] = &["Apple Color Emoji", ".AppleColorEmojiUI"];
15
16#[cfg(target_os = "windows")]
17const EMOJI_FONT_FAMILIES: &[&str] = &["Segoe UI Emoji", "Segoe UI Symbol"];
18
19#[cfg(any(target_os = "linux", target_os = "freebsd"))]
20const EMOJI_FONT_FAMILIES: &[&str] = &[
21 "Noto Color Emoji",
22 "Emoji One",
23 "Twitter Color Emoji",
24 "JoyPixels",
25];
26
27#[cfg(not(any(
28 target_os = "macos",
29 target_os = "windows",
30 target_os = "linux",
31 target_os = "freebsd",
32)))]
33const EMOJI_FONT_FAMILIES: &[&str] = &[];
34
35fn is_emoji_presentation(c: char) -> bool {
36 static EMOJI_PRESENTATION_REGEX: LazyLock<regex::Regex> =
37 LazyLock::new(|| regex::Regex::new("\\p{Emoji_Presentation}").unwrap());
38 let mut buf = [0u8; 4];
39 EMOJI_PRESENTATION_REGEX.is_match(c.encode_utf8(&mut buf))
40}
41
42fn font_has_char(db: &usvg::fontdb::Database, id: usvg::fontdb::ID, ch: char) -> bool {
43 db.with_face_data(id, |font_data, face_index| {
44 ttf_parser::Face::parse(font_data, face_index)
45 .ok()
46 .and_then(|face| face.glyph_index(ch))
47 .is_some()
48 })
49 .unwrap_or(false)
50}
51
52fn select_emoji_font(
53 ch: char,
54 fonts: &[usvg::fontdb::ID],
55 db: &usvg::fontdb::Database,
56 families: &[&str],
57) -> Option<usvg::fontdb::ID> {
58 for family_name in families {
59 let query = usvg::fontdb::Query {
60 families: &[usvg::fontdb::Family::Name(family_name)],
61 weight: usvg::fontdb::Weight(400),
62 stretch: usvg::fontdb::Stretch::Normal,
63 style: usvg::fontdb::Style::Normal,
64 };
65
66 let Some(id) = db.query(&query) else {
67 continue;
68 };
69
70 if fonts.contains(&id) || !font_has_char(db, id, ch) {
71 continue;
72 }
73
74 return Some(id);
75 }
76
77 None
78}
79
80/// When rendering SVGs, we render them at twice the size to get a higher-quality result.
81pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.;
82
83#[derive(Clone, PartialEq, Hash, Eq)]
84#[expect(missing_docs)]
85pub struct RenderSvgParams {
86 pub path: SharedString,
87 pub size: Size<DevicePixels>,
88}
89
90#[derive(Clone)]
91/// A struct holding everything necessary to render SVGs.
92pub struct SvgRenderer {
93 asset_source: Arc<dyn AssetSource>,
94 usvg_options: Arc<usvg::Options<'static>>,
95}
96
97/// The size in which to render the SVG.
98pub enum SvgSize {
99 /// An absolute size in device pixels.
100 Size(Size<DevicePixels>),
101 /// A scaling factor to apply to the size provided by the SVG.
102 ScaleFactor(f32),
103}
104
105impl SvgRenderer {
106 /// Creates a new SVG renderer with the provided asset source.
107 pub fn new(asset_source: Arc<dyn AssetSource>) -> Self {
108 static SYSTEM_FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
109 let mut db = usvg::fontdb::Database::new();
110 db.load_system_fonts();
111 Arc::new(db)
112 });
113
114 // Build the enriched font DB lazily on first SVG render rather than
115 // eagerly at construction time. This avoids the expensive deep-clone
116 // of the system font database for code paths that never render SVGs
117 // (e.g. tests).
118 let enriched_fontdb: Arc<OnceLock<Arc<usvg::fontdb::Database>>> = Arc::new(OnceLock::new());
119
120 let default_font_resolver = usvg::FontResolver::default_font_selector();
121 let font_resolver = Box::new({
122 let asset_source = asset_source.clone();
123 move |font: &usvg::Font, db: &mut Arc<usvg::fontdb::Database>| {
124 if db.is_empty() {
125 let fontdb = enriched_fontdb.get_or_init(|| {
126 let mut db = (**SYSTEM_FONT_DB).clone();
127 load_bundled_fonts(&*asset_source, &mut db);
128 fix_generic_font_families(&mut db);
129 Arc::new(db)
130 });
131 *db = fontdb.clone();
132 }
133 if let Some(id) = default_font_resolver(font, db) {
134 return Some(id);
135 }
136 // fontdb doesn't recognize CSS system font keywords like "system-ui"
137 // or "ui-sans-serif", so fall back to sans-serif before any face.
138 let sans_query = usvg::fontdb::Query {
139 families: &[usvg::fontdb::Family::SansSerif],
140 ..Default::default()
141 };
142 db.query(&sans_query)
143 .or_else(|| db.faces().next().map(|f| f.id))
144 }
145 });
146 let default_fallback_selection = usvg::FontResolver::default_fallback_selector();
147 let fallback_selection = Box::new(
148 move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc<usvg::fontdb::Database>| {
149 if is_emoji_presentation(ch) {
150 if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES)
151 {
152 return Some(id);
153 }
154 }
155
156 default_fallback_selection(ch, fonts, db)
157 },
158 );
159 let options = usvg::Options {
160 font_resolver: usvg::FontResolver {
161 select_font: font_resolver,
162 select_fallback: fallback_selection,
163 },
164 ..Default::default()
165 };
166 Self {
167 asset_source,
168 usvg_options: Arc::new(options),
169 }
170 }
171
172 /// Renders the given bytes into an image buffer.
173 pub fn render_single_frame(
174 &self,
175 bytes: &[u8],
176 scale_factor: f32,
177 ) -> Result<Arc<RenderImage>, usvg::Error> {
178 self.render_pixmap(
179 bytes,
180 SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR),
181 )
182 .map(|pixmap| {
183 let mut buffer =
184 image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take())
185 .unwrap();
186
187 for pixel in buffer.chunks_exact_mut(4) {
188 swap_rgba_pa_to_bgra(pixel);
189 }
190
191 let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)]));
192 image.scale_factor = SMOOTH_SVG_SCALE_FACTOR;
193 Arc::new(image)
194 })
195 }
196
197 pub(crate) fn render_alpha_mask(
198 &self,
199 params: &RenderSvgParams,
200 bytes: Option<&[u8]>,
201 ) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
202 anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
203
204 let render_pixmap = |bytes| {
205 let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?;
206
207 // Convert the pixmap's pixels into an alpha mask.
208 let size = Size::new(
209 DevicePixels(pixmap.width() as i32),
210 DevicePixels(pixmap.height() as i32),
211 );
212 let alpha_mask = pixmap
213 .pixels()
214 .iter()
215 .map(|p| p.alpha())
216 .collect::<Vec<_>>();
217
218 Ok(Some((size, alpha_mask)))
219 };
220
221 if let Some(bytes) = bytes {
222 render_pixmap(bytes)
223 } else if let Some(bytes) = self.asset_source.load(¶ms.path)? {
224 render_pixmap(&bytes)
225 } else {
226 Ok(None)
227 }
228 }
229
230 fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
231 // Cap the size of the rendered pixmap to avoid texture allocation panics
232 // Related issue: #56466
233 const MAX_SIZE: f32 = 8192.0;
234
235 let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
236 let svg_size = tree.size();
237 let mut scale = match size {
238 SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(),
239 SvgSize::ScaleFactor(scale) => scale,
240 };
241
242 let width = svg_size.width() * scale;
243 if width > MAX_SIZE {
244 log::warn!("Attempted to render pixmap where width ({width}) > MAX_SIZE ({MAX_SIZE})");
245 scale *= MAX_SIZE / width;
246 }
247 let height = svg_size.height() * scale;
248 if height > MAX_SIZE {
249 log::warn!(
250 "Attempted to render pixmap where height ({height}) > MAX_SIZE ({MAX_SIZE})"
251 );
252 scale *= MAX_SIZE / height;
253 }
254
255 // Render the SVG to a pixmap with the specified width and height.
256 let mut pixmap = resvg::tiny_skia::Pixmap::new(
257 (svg_size.width() * scale) as u32,
258 (svg_size.height() * scale) as u32,
259 )
260 .ok_or(usvg::Error::InvalidSize)?;
261
262 let transform = resvg::tiny_skia::Transform::from_scale(scale, scale);
263
264 resvg::render(&tree, transform, &mut pixmap.as_mut());
265
266 Ok(pixmap)
267 }
268}
269
270fn load_bundled_fonts(asset_source: &dyn AssetSource, db: &mut usvg::fontdb::Database) {
271 let font_paths = [
272 "fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
273 "fonts/lilex/Lilex-Regular.ttf",
274 ];
275 for path in font_paths {
276 match asset_source.load(path) {
277 Ok(Some(data)) => db.load_font_data(data.into_owned()),
278 Ok(None) => log::warn!("Bundled font not found: {path}"),
279 Err(error) => log::warn!("Failed to load bundled font {path}: {error}"),
280 }
281 }
282}
283
284// fontdb defaults generic families to Microsoft fonts ("Arial", "Times New Roman")
285// which aren't installed on most Linux systems. fontconfig normally overrides these,
286// but when it fails the defaults remain and all generic family queries return None.
287fn fix_generic_font_families(db: &mut usvg::fontdb::Database) {
288 use usvg::fontdb::{Family, Query};
289
290 let families_and_fallbacks: &[(Family<'_>, &str)] = &[
291 (Family::SansSerif, "IBM Plex Sans"),
292 // No serif font bundled; use sans-serif as best available fallback.
293 (Family::Serif, "IBM Plex Sans"),
294 (Family::Monospace, "Lilex"),
295 (Family::Cursive, "IBM Plex Sans"),
296 (Family::Fantasy, "IBM Plex Sans"),
297 ];
298
299 for (family, fallback_name) in families_and_fallbacks {
300 let query = Query {
301 families: &[*family],
302 ..Default::default()
303 };
304 if db.query(&query).is_none() {
305 match family {
306 Family::SansSerif => db.set_sans_serif_family(*fallback_name),
307 Family::Serif => db.set_serif_family(*fallback_name),
308 Family::Monospace => db.set_monospace_family(*fallback_name),
309 Family::Cursive => db.set_cursive_family(*fallback_name),
310 Family::Fantasy => db.set_fantasy_family(*fallback_name),
311 _ => {}
312 }
313 }
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use usvg::fontdb::{Database, Family, Query};
321
322 const IBM_PLEX_REGULAR: &[u8] =
323 include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
324 const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf");
325
326 fn db_with_bundled_fonts() -> Database {
327 let mut db = Database::new();
328 db.load_font_data(IBM_PLEX_REGULAR.to_vec());
329 db.load_font_data(LILEX_REGULAR.to_vec());
330 db
331 }
332
333 #[test]
334 fn text_with_split_glyph_clusters_in_mixed_fonts_does_not_panic() {
335 let mut db = Database::new();
336 db.load_font_data(IBM_PLEX_REGULAR.to_vec());
337 db.load_font_data(LILEX_REGULAR.to_vec());
338 let options = usvg::Options {
339 fontdb: std::sync::Arc::new(db),
340 ..Default::default()
341 };
342
343 // A base letter followed by a stack of combining marks. Under HarfBuzz's
344 // default cluster merging every mark glyph shares the base's byte index,
345 // which is the "glyph splitting" condition that triggered the panic. The
346 // chunk must use two different fonts so the buggy merge path runs.
347 let zalgo = "e\u{0301}\u{0302}\u{0303}\u{0304}\u{0306}\u{0307}\u{0308}\u{030a}";
348 let svg = format!(
349 r#"<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><text font-family="Lilex" font-size="32">{zalgo}<tspan font-family="IBM Plex Sans">{zalgo}</tspan></text></svg>"#
350 );
351
352 // Before the fix this aborts via panic with a message like
353 // "removal index (is 5) should be < len (is 5)".
354 usvg::Tree::from_data(svg.as_bytes(), &options)
355 .expect("SVG with mixed-font text should parse");
356 }
357
358 #[test]
359 fn test_is_emoji_presentation() {
360 let cases = [
361 ("a", false),
362 ("Z", false),
363 ("1", false),
364 ("#", false),
365 ("*", false),
366 ("漢", false),
367 ("中", false),
368 ("カ", false),
369 ("©", false),
370 ("♥", false),
371 ("😀", true),
372 ("✅", true),
373 ("🇺🇸", true),
374 // SVG fallback is not cluster-aware yet
375 ("©️", false),
376 ("♥️", false),
377 ("1️⃣", false),
378 ];
379 for (s, expected) in cases {
380 assert_eq!(
381 is_emoji_presentation(s.chars().next().unwrap()),
382 expected,
383 "for char {:?}",
384 s
385 );
386 }
387 }
388
389 #[test]
390 fn fix_generic_font_families_sets_all_families() {
391 let mut db = db_with_bundled_fonts();
392 fix_generic_font_families(&mut db);
393
394 let families = [
395 Family::SansSerif,
396 Family::Serif,
397 Family::Monospace,
398 Family::Cursive,
399 Family::Fantasy,
400 ];
401
402 for family in families {
403 let query = Query {
404 families: &[family],
405 ..Default::default()
406 };
407 assert!(
408 db.query(&query).is_some(),
409 "Expected generic family {family:?} to resolve after fix_generic_font_families"
410 );
411 }
412 }
413
414 #[test]
415 fn test_select_emoji_font_skips_family_without_glyph() {
416 let mut db = db_with_bundled_fonts();
417
418 let ibm_plex_sans = db
419 .query(&usvg::fontdb::Query {
420 families: &[usvg::fontdb::Family::Name("IBM Plex Sans")],
421 weight: usvg::fontdb::Weight(400),
422 stretch: usvg::fontdb::Stretch::Normal,
423 style: usvg::fontdb::Style::Normal,
424 })
425 .unwrap();
426 let lilex = db
427 .query(&usvg::fontdb::Query {
428 families: &[usvg::fontdb::Family::Name("Lilex")],
429 weight: usvg::fontdb::Weight(400),
430 stretch: usvg::fontdb::Stretch::Normal,
431 style: usvg::fontdb::Style::Normal,
432 })
433 .unwrap();
434 let selected = select_emoji_font('│', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap();
435
436 assert_eq!(selected, lilex);
437 assert!(!font_has_char(&db, ibm_plex_sans, '│'));
438 assert!(font_has_char(&db, selected, '│'));
439 }
440
441 #[test]
442 fn fix_generic_font_families_monospace_resolves_to_lilex() {
443 let mut db = db_with_bundled_fonts();
444 fix_generic_font_families(&mut db);
445
446 let query = Query {
447 families: &[Family::Monospace],
448 ..Default::default()
449 };
450 let id = db.query(&query).expect("Monospace should resolve");
451 let face = db.face(id).expect("Face should exist");
452 assert!(
453 face.families.iter().any(|(name, _)| name.contains("Lilex")),
454 "Monospace should map to Lilex, got {:?}",
455 face.families
456 );
457 }
458}
459