1use crate::{
2 AssetSource, DevicePixels, IsZero, RenderImage, RenderSvgParams, Result, Size,
3 swap_rgba_pa_to_bgra,
4};
5use image::Frame;
6use resvg::tiny_skia::Pixmap;
7use smallvec::SmallVec;
8use std::sync::{Arc, LazyLock, OnceLock};
9
10#[cfg(target_os = "macos")]
11const EMOJI_FONT_FAMILIES: &[&str] = &["Apple Color Emoji", ".AppleColorEmojiUI"];
12
13#[cfg(target_os = "windows")]
14const EMOJI_FONT_FAMILIES: &[&str] = &["Segoe UI Emoji", "Segoe UI Symbol"];
15
16#[cfg(any(target_os = "linux", target_os = "freebsd"))]
17const EMOJI_FONT_FAMILIES: &[&str] = &[
18 "Noto Color Emoji",
19 "Emoji One",
20 "Twitter Color Emoji",
21 "JoyPixels",
22];
23
24#[cfg(not(any(
25 target_os = "macos",
26 target_os = "windows",
27 target_os = "linux",
28 target_os = "freebsd",
29)))]
30const EMOJI_FONT_FAMILIES: &[&str] = &[];
31
32fn is_emoji_presentation(c: char) -> bool {
33 static EMOJI_PRESENTATION_REGEX: LazyLock<regex::Regex> =
34 LazyLock::new(|| regex::Regex::new("\\p{Emoji_Presentation}").unwrap());
35 let mut buf = [0u8; 4];
36 EMOJI_PRESENTATION_REGEX.is_match(c.encode_utf8(&mut buf))
37}
38
39fn font_has_char(db: &usvg::fontdb::Database, id: usvg::fontdb::ID, ch: char) -> bool {
40 db.with_face_data(id, |font_data, face_index| {
41 ttf_parser::Face::parse(font_data, face_index)
42 .ok()
43 .and_then(|face| face.glyph_index(ch))
44 .is_some()
45 })
46 .unwrap_or(false)
47}
48
49fn select_emoji_font(
50 ch: char,
51 fonts: &[usvg::fontdb::ID],
52 db: &usvg::fontdb::Database,
53 families: &[&str],
54) -> Option<usvg::fontdb::ID> {
55 for family_name in families {
56 let query = usvg::fontdb::Query {
57 families: &[usvg::fontdb::Family::Name(family_name)],
58 weight: usvg::fontdb::Weight(400),
59 stretch: usvg::fontdb::Stretch::Normal,
60 style: usvg::fontdb::Style::Normal,
61 };
62
63 let Some(id) = db.query(&query) else {
64 continue;
65 };
66
67 if fonts.contains(&id) || !font_has_char(db, id, ch) {
68 continue;
69 }
70
71 return Some(id);
72 }
73
74 None
75}
76
77pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.;
79
80#[derive(Clone)]
81pub struct SvgRenderer {
83 asset_source: Arc<dyn AssetSource>,
84 usvg_options: Arc<usvg::Options<'static>>,
85}
86
87pub struct ParsedSvg(usvg::Tree);
94
95#[derive(Clone, Copy)]
97pub enum SvgSize {
98 Size(Size<DevicePixels>),
100 ExactSize(Size<DevicePixels>),
102 ScaleFactor(f32),
104}
105
106impl From<f32> for SvgSize {
107 fn from(scale_factor: f32) -> Self {
108 Self::ScaleFactor(scale_factor)
109 }
110}
111
112impl SvgRenderer {
113 pub fn new(asset_source: Arc<dyn AssetSource>) -> Self {
115 static SYSTEM_FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
116 let mut db = usvg::fontdb::Database::new();
117 db.load_system_fonts();
118 Arc::new(db)
119 });
120
121 let enriched_fontdb: Arc<OnceLock<Arc<usvg::fontdb::Database>>> = Arc::new(OnceLock::new());
126
127 let default_font_resolver = usvg::FontResolver::default_font_selector();
128 let font_resolver = Box::new({
129 let asset_source = asset_source.clone();
130 move |font: &usvg::Font, db: &mut Arc<usvg::fontdb::Database>| {
131 if db.is_empty() {
132 let fontdb = enriched_fontdb.get_or_init(|| {
133 let mut db = (**SYSTEM_FONT_DB).clone();
134 load_bundled_fonts(&*asset_source, &mut db);
135 fix_generic_font_families(&mut db);
136 Arc::new(db)
137 });
138 *db = fontdb.clone();
139 }
140 if let Some(id) = default_font_resolver(font, db) {
141 return Some(id);
142 }
143 let sans_query = usvg::fontdb::Query {
146 families: &[usvg::fontdb::Family::SansSerif],
147 ..Default::default()
148 };
149 db.query(&sans_query)
150 .or_else(|| db.faces().next().map(|f| f.id))
151 }
152 });
153 let default_fallback_selection = usvg::FontResolver::default_fallback_selector();
154 let fallback_selection = Box::new(
155 move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc<usvg::fontdb::Database>| {
156 if is_emoji_presentation(ch) {
157 if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES)
158 {
159 return Some(id);
160 }
161 }
162
163 default_fallback_selection(ch, fonts, db)
164 },
165 );
166 let options = usvg::Options {
167 font_resolver: usvg::FontResolver {
168 select_font: font_resolver,
169 select_fallback: fallback_selection,
170 },
171 ..Default::default()
172 };
173 Self {
174 asset_source,
175 usvg_options: Arc::new(options),
176 }
177 }
178
179 #[ztracing::instrument(skip_all)]
181 pub fn parse_svg(&self, bytes: &[u8]) -> Result<ParsedSvg, usvg::Error> {
182 usvg::Tree::from_data(bytes, &self.usvg_options).map(ParsedSvg)
183 }
184
185 #[ztracing::instrument(skip_all)]
187 pub fn render_parsed(
188 &self,
189 svg: &ParsedSvg,
190 size: impl Into<SvgSize>,
191 ) -> Result<Arc<RenderImage>, usvg::Error> {
192 let (size, image_scale_factor) = match size.into() {
193 SvgSize::Size(size) => (SvgSize::Size(size), 1.0),
194 SvgSize::ExactSize(size) => (SvgSize::ExactSize(size), 1.0),
195 SvgSize::ScaleFactor(scale_factor) => (
196 SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR),
197 SMOOTH_SVG_SCALE_FACTOR,
198 ),
199 };
200 let pixmap = rasterize_tree(&svg.0, size)?;
201 let mut buffer =
202 image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap();
203
204 for pixel in buffer.chunks_exact_mut(4) {
205 swap_rgba_pa_to_bgra(pixel);
206 }
207
208 let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)]));
209 image.scale_factor = image_scale_factor;
210 Ok(Arc::new(image))
211 }
212
213 pub fn render_single_frame(
215 &self,
216 bytes: &[u8],
217 scale_factor: f32,
218 ) -> Result<Arc<RenderImage>, usvg::Error> {
219 let svg = self.parse_svg(bytes)?;
220 self.render_parsed(&svg, scale_factor)
221 }
222
223 pub(crate) fn render_alpha_mask(
224 &self,
225 params: &RenderSvgParams,
226 bytes: Option<&[u8]>,
227 ) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
228 anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
229
230 let render_pixmap = |bytes| {
231 let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?;
232
233 let size = Size::new(
235 DevicePixels(pixmap.width() as i32),
236 DevicePixels(pixmap.height() as i32),
237 );
238 let alpha_mask = pixmap
239 .pixels()
240 .iter()
241 .map(|p| p.alpha())
242 .collect::<Vec<_>>();
243
244 Ok(Some((size, alpha_mask)))
245 };
246
247 if let Some(bytes) = bytes {
248 render_pixmap(bytes)
249 } else if let Some(bytes) = self.asset_source.load(¶ms.path)? {
250 render_pixmap(&bytes)
251 } else {
252 Ok(None)
253 }
254 }
255
256 fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
257 let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
258 rasterize_tree(&tree, size)
259 }
260}
261
262fn rasterize_tree(tree: &usvg::Tree, size: SvgSize) -> Result<Pixmap, usvg::Error> {
263 const MAX_SIZE: f32 = 8192.0;
266
267 let svg_size = tree.size();
268 let (mut width, mut height) = match size {
269 SvgSize::Size(size) => {
270 let scale = i32::from(size.width) as f32 / svg_size.width();
271 (svg_size.width() * scale, svg_size.height() * scale)
272 }
273 SvgSize::ExactSize(size) => (i32::from(size.width) as f32, i32::from(size.height) as f32),
274 SvgSize::ScaleFactor(scale) => (svg_size.width() * scale, svg_size.height() * scale),
275 };
276
277 if width > MAX_SIZE {
278 log::warn!("Attempted to render pixmap where width ({width}) > MAX_SIZE ({MAX_SIZE})");
279 }
280 if height > MAX_SIZE {
281 log::warn!("Attempted to render pixmap where height ({height}) > MAX_SIZE ({MAX_SIZE})");
282 }
283 let scale = (MAX_SIZE / width).min(MAX_SIZE / height).min(1.0);
284 width *= scale;
285 height *= scale;
286
287 let mut pixmap = resvg::tiny_skia::Pixmap::new(width as u32, height as u32)
289 .ok_or(usvg::Error::InvalidSize)?;
290
291 let transform = resvg::tiny_skia::Transform::from_scale(
292 width / svg_size.width(),
293 height / svg_size.height(),
294 );
295
296 resvg::render(tree, transform, &mut pixmap.as_mut());
297
298 Ok(pixmap)
299}
300
301fn load_bundled_fonts(asset_source: &dyn AssetSource, db: &mut usvg::fontdb::Database) {
302 let font_paths = [
303 "fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
304 "fonts/lilex/Lilex-Regular.ttf",
305 ];
306 for path in font_paths {
307 match asset_source.load(path) {
308 Ok(Some(data)) => db.load_font_data(data.into_owned()),
309 Ok(None) => log::warn!("Bundled font not found: {path}"),
310 Err(error) => log::warn!("Failed to load bundled font {path}: {error}"),
311 }
312 }
313}
314
315fn fix_generic_font_families(db: &mut usvg::fontdb::Database) {
319 use usvg::fontdb::{Family, Query};
320
321 let families_and_fallbacks: &[(Family<'_>, &str)] = &[
322 (Family::SansSerif, "IBM Plex Sans"),
323 (Family::Serif, "IBM Plex Sans"),
325 (Family::Monospace, "Lilex"),
326 (Family::Cursive, "IBM Plex Sans"),
327 (Family::Fantasy, "IBM Plex Sans"),
328 ];
329
330 for (family, fallback_name) in families_and_fallbacks {
331 let query = Query {
332 families: &[*family],
333 ..Default::default()
334 };
335 if db.query(&query).is_none() {
336 match family {
337 Family::SansSerif => db.set_sans_serif_family(*fallback_name),
338 Family::Serif => db.set_serif_family(*fallback_name),
339 Family::Monospace => db.set_monospace_family(*fallback_name),
340 Family::Cursive => db.set_cursive_family(*fallback_name),
341 Family::Fantasy => db.set_fantasy_family(*fallback_name),
342 _ => {}
343 }
344 }
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use usvg::fontdb::{Database, Family, Query};
352
353 const IBM_PLEX_REGULAR: &[u8] =
354 include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
355 const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf");
356
357 #[test]
358 fn renders_parsed_svg_at_requested_size() -> Result<()> {
359 let renderer = SvgRenderer::new(Arc::new(()));
360 let svg = renderer.parse_svg(
361 br#"<svg xmlns="http://www.w3.org/2000/svg" width="24pt" height="12pt"></svg>"#,
362 )?;
363 let requested_size = Size::new(DevicePixels(24), DevicePixels(12));
364 let image = renderer.render_parsed(&svg, SvgSize::ExactSize(requested_size))?;
365
366 assert_eq!(image.size(0), requested_size);
367 Ok(())
368 }
369
370 #[test]
371 fn preserves_aspect_ratio_for_width_constrained_size() -> Result<()> {
372 let renderer = SvgRenderer::new(Arc::new(()));
373 let svg = renderer.parse_svg(
374 br#"<svg xmlns="http://www.w3.org/2000/svg" width="24pt" height="12pt"></svg>"#,
375 )?;
376 let image = renderer.render_parsed(
377 &svg,
378 SvgSize::Size(Size::new(DevicePixels(24), DevicePixels(24))),
379 )?;
380
381 assert_eq!(image.size(0), Size::new(DevicePixels(24), DevicePixels(12)));
382 Ok(())
383 }
384
385 fn db_with_bundled_fonts() -> Database {
386 let mut db = Database::new();
387 db.load_font_data(IBM_PLEX_REGULAR.to_vec());
388 db.load_font_data(LILEX_REGULAR.to_vec());
389 db
390 }
391
392 #[test]
393 fn text_with_split_glyph_clusters_in_mixed_fonts_does_not_panic() {
394 let mut db = Database::new();
395 db.load_font_data(IBM_PLEX_REGULAR.to_vec());
396 db.load_font_data(LILEX_REGULAR.to_vec());
397 let options = usvg::Options {
398 fontdb: std::sync::Arc::new(db),
399 ..Default::default()
400 };
401
402 let zalgo = "e\u{0301}\u{0302}\u{0303}\u{0304}\u{0306}\u{0307}\u{0308}\u{030a}";
407 let svg = format!(
408 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>"#
409 );
410
411 usvg::Tree::from_data(svg.as_bytes(), &options)
414 .expect("SVG with mixed-font text should parse");
415 }
416
417 #[test]
418 fn test_is_emoji_presentation() {
419 let cases = [
420 ("a", false),
421 ("Z", false),
422 ("1", false),
423 ("#", false),
424 ("*", false),
425 ("漢", false),
426 ("中", false),
427 ("カ", false),
428 ("©", false),
429 ("♥", false),
430 ("😀", true),
431 ("✅", true),
432 ("🇺🇸", true),
433 ("©️", false),
435 ("♥️", false),
436 ("1️⃣", false),
437 ];
438 for (s, expected) in cases {
439 assert_eq!(
440 is_emoji_presentation(s.chars().next().unwrap()),
441 expected,
442 "for char {:?}",
443 s
444 );
445 }
446 }
447
448 #[test]
449 fn fix_generic_font_families_sets_all_families() {
450 let mut db = db_with_bundled_fonts();
451 fix_generic_font_families(&mut db);
452
453 let families = [
454 Family::SansSerif,
455 Family::Serif,
456 Family::Monospace,
457 Family::Cursive,
458 Family::Fantasy,
459 ];
460
461 for family in families {
462 let query = Query {
463 families: &[family],
464 ..Default::default()
465 };
466 assert!(
467 db.query(&query).is_some(),
468 "Expected generic family {family:?} to resolve after fix_generic_font_families"
469 );
470 }
471 }
472
473 #[test]
474 fn test_select_emoji_font_skips_family_without_glyph() {
475 let mut db = db_with_bundled_fonts();
476
477 let ibm_plex_sans = db
478 .query(&usvg::fontdb::Query {
479 families: &[usvg::fontdb::Family::Name("IBM Plex Sans")],
480 weight: usvg::fontdb::Weight(400),
481 stretch: usvg::fontdb::Stretch::Normal,
482 style: usvg::fontdb::Style::Normal,
483 })
484 .unwrap();
485 let lilex = db
486 .query(&usvg::fontdb::Query {
487 families: &[usvg::fontdb::Family::Name("Lilex")],
488 weight: usvg::fontdb::Weight(400),
489 stretch: usvg::fontdb::Stretch::Normal,
490 style: usvg::fontdb::Style::Normal,
491 })
492 .unwrap();
493 let selected = select_emoji_font('│', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap();
494
495 assert_eq!(selected, lilex);
496 assert!(!font_has_char(&db, ibm_plex_sans, '│'));
497 assert!(font_has_char(&db, selected, '│'));
498 }
499
500 #[test]
501 fn fix_generic_font_families_monospace_resolves_to_lilex() {
502 let mut db = db_with_bundled_fonts();
503 fix_generic_font_families(&mut db);
504
505 let query = Query {
506 families: &[Family::Monospace],
507 ..Default::default()
508 };
509 let id = db.query(&query).expect("Monospace should resolve");
510 let face = db.face(id).expect("Face should exist");
511 assert!(
512 face.families.iter().any(|(name, _)| name.contains("Lilex")),
513 "Monospace should map to Lilex, got {:?}",
514 face.families
515 );
516 }
517}