1use crate::{
2 AssetSource, Bounds, 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
80pub 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)]
91pub struct SvgRenderer {
93 asset_source: Arc<dyn AssetSource>,
94 usvg_options: Arc<usvg::Options<'static>>,
95}
96
97pub struct ParsedSvg(usvg::Tree);
104
105impl ParsedSvg {
106 pub fn size(&self) -> Size<f32> {
108 let size = self.0.size();
109 Size::new(size.width(), size.height())
110 }
111}
112
113#[derive(Clone, Copy)]
115pub enum SvgSize {
116 Size(Size<DevicePixels>),
118 ExactSize(Size<DevicePixels>),
120 Viewport {
124 size: Size<DevicePixels>,
126 view_box: Bounds<f32>,
128 },
129 ScaleFactor(f32),
131}
132
133impl From<f32> for SvgSize {
134 fn from(scale_factor: f32) -> Self {
135 Self::ScaleFactor(scale_factor)
136 }
137}
138
139impl SvgRenderer {
140 pub fn new(asset_source: Arc<dyn AssetSource>) -> Self {
142 static SYSTEM_FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
143 let mut db = usvg::fontdb::Database::new();
144 db.load_system_fonts();
145 Arc::new(db)
146 });
147
148 let enriched_fontdb: Arc<OnceLock<Arc<usvg::fontdb::Database>>> = Arc::new(OnceLock::new());
153
154 let default_font_resolver = usvg::FontResolver::default_font_selector();
155 let font_resolver = Box::new({
156 let asset_source = asset_source.clone();
157 move |font: &usvg::Font, db: &mut Arc<usvg::fontdb::Database>| {
158 if db.is_empty() {
159 let fontdb = enriched_fontdb.get_or_init(|| {
160 let mut db = (**SYSTEM_FONT_DB).clone();
161 load_bundled_fonts(&*asset_source, &mut db);
162 fix_generic_font_families(&mut db);
163 Arc::new(db)
164 });
165 *db = fontdb.clone();
166 }
167 if let Some(id) = default_font_resolver(font, db) {
168 return Some(id);
169 }
170 let sans_query = usvg::fontdb::Query {
173 families: &[usvg::fontdb::Family::SansSerif],
174 ..Default::default()
175 };
176 db.query(&sans_query)
177 .or_else(|| db.faces().next().map(|f| f.id))
178 }
179 });
180 let default_fallback_selection = usvg::FontResolver::default_fallback_selector();
181 let fallback_selection = Box::new(
182 move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc<usvg::fontdb::Database>| {
183 if is_emoji_presentation(ch) {
184 if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES)
185 {
186 return Some(id);
187 }
188 }
189
190 default_fallback_selection(ch, fonts, db)
191 },
192 );
193 let options = usvg::Options {
194 font_resolver: usvg::FontResolver {
195 select_font: font_resolver,
196 select_fallback: fallback_selection,
197 },
198 ..Default::default()
199 };
200 Self {
201 asset_source,
202 usvg_options: Arc::new(options),
203 }
204 }
205
206 pub fn parse_svg(&self, bytes: &[u8]) -> Result<ParsedSvg, usvg::Error> {
208 usvg::Tree::from_data(bytes, &self.usvg_options).map(ParsedSvg)
209 }
210
211 pub fn render_parsed(
213 &self,
214 svg: &ParsedSvg,
215 size: impl Into<SvgSize>,
216 ) -> Result<Arc<RenderImage>, usvg::Error> {
217 let (size, image_scale_factor) = match size.into() {
218 SvgSize::Size(size) => (SvgSize::Size(size), 1.0),
219 SvgSize::ExactSize(size) => (SvgSize::ExactSize(size), 1.0),
220 viewport @ SvgSize::Viewport { .. } => (viewport, 1.0),
221 SvgSize::ScaleFactor(scale_factor) => (
222 SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR),
223 SMOOTH_SVG_SCALE_FACTOR,
224 ),
225 };
226 let pixmap = rasterize_tree(&svg.0, size)?;
227 let mut buffer =
228 image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap();
229
230 for pixel in buffer.chunks_exact_mut(4) {
231 swap_rgba_pa_to_bgra(pixel);
232 }
233
234 let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)]));
235 image.scale_factor = image_scale_factor;
236 Ok(Arc::new(image))
237 }
238
239 pub fn render_single_frame(
241 &self,
242 bytes: &[u8],
243 scale_factor: f32,
244 ) -> Result<Arc<RenderImage>, usvg::Error> {
245 let svg = self.parse_svg(bytes)?;
246 self.render_parsed(&svg, scale_factor)
247 }
248
249 pub(crate) fn render_alpha_mask(
250 &self,
251 params: &RenderSvgParams,
252 bytes: Option<&[u8]>,
253 ) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
254 anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
255
256 let render_pixmap = |bytes| {
257 let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?;
258
259 let size = Size::new(
261 DevicePixels(pixmap.width() as i32),
262 DevicePixels(pixmap.height() as i32),
263 );
264 let alpha_mask = pixmap
265 .pixels()
266 .iter()
267 .map(|p| p.alpha())
268 .collect::<Vec<_>>();
269
270 Ok(Some((size, alpha_mask)))
271 };
272
273 if let Some(bytes) = bytes {
274 render_pixmap(bytes)
275 } else if let Some(bytes) = self.asset_source.load(¶ms.path)? {
276 render_pixmap(&bytes)
277 } else {
278 Ok(None)
279 }
280 }
281
282 fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
283 let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
284 rasterize_tree(&tree, size)
285 }
286}
287
288fn rasterize_tree(tree: &usvg::Tree, size: SvgSize) -> Result<Pixmap, usvg::Error> {
289 const MAX_SIZE: f32 = 8192.0;
292
293 if let SvgSize::Viewport { size, view_box } = size {
294 let width = i32::from(size.width);
295 let height = i32::from(size.height);
296 if !(1..=8192).contains(&width)
297 || !(1..=8192).contains(&height)
298 || ![
299 view_box.origin.x,
300 view_box.origin.y,
301 view_box.size.width,
302 view_box.size.height,
303 ]
304 .iter()
305 .all(|v| v.is_finite())
306 || view_box.size.width <= 0.
307 || view_box.size.height <= 0.
308 {
309 return Err(usvg::Error::InvalidSize);
310 }
311 let sx = width as f32 / view_box.size.width;
312 let sy = height as f32 / view_box.size.height;
313 let transform = resvg::tiny_skia::Transform::from_row(
314 sx,
315 0.,
316 0.,
317 sy,
318 -view_box.origin.x * sx,
319 -view_box.origin.y * sy,
320 );
321 if ![sx, sy, transform.tx, transform.ty]
322 .iter()
323 .all(|v| v.is_finite())
324 {
325 return Err(usvg::Error::InvalidSize);
326 }
327 let mut pixmap =
328 Pixmap::new(width as u32, height as u32).ok_or(usvg::Error::InvalidSize)?;
329 resvg::render(tree, transform, &mut pixmap.as_mut());
330 return Ok(pixmap);
331 }
332 let svg_size = tree.size();
333 let (mut width, mut height) = match size {
334 SvgSize::Size(size) => {
335 let scale = i32::from(size.width) as f32 / svg_size.width();
336 (svg_size.width() * scale, svg_size.height() * scale)
337 }
338 SvgSize::ExactSize(size) => (i32::from(size.width) as f32, i32::from(size.height) as f32),
339 SvgSize::ScaleFactor(scale) => (svg_size.width() * scale, svg_size.height() * scale),
340 SvgSize::Viewport { .. } => unreachable!("viewport handled above"),
341 };
342
343 if width > MAX_SIZE {
344 log::warn!("Attempted to render pixmap where width ({width}) > MAX_SIZE ({MAX_SIZE})");
345 }
346 if height > MAX_SIZE {
347 log::warn!("Attempted to render pixmap where height ({height}) > MAX_SIZE ({MAX_SIZE})");
348 }
349 let scale = (MAX_SIZE / width).min(MAX_SIZE / height).min(1.0);
350 width *= scale;
351 height *= scale;
352
353 let mut pixmap = resvg::tiny_skia::Pixmap::new(width as u32, height as u32)
355 .ok_or(usvg::Error::InvalidSize)?;
356
357 let transform = resvg::tiny_skia::Transform::from_scale(
358 width / svg_size.width(),
359 height / svg_size.height(),
360 );
361
362 resvg::render(tree, transform, &mut pixmap.as_mut());
363
364 Ok(pixmap)
365}
366
367fn load_bundled_fonts(asset_source: &dyn AssetSource, db: &mut usvg::fontdb::Database) {
368 let font_paths = [
369 "fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
370 "fonts/lilex/Lilex-Regular.ttf",
371 ];
372 for path in font_paths {
373 match asset_source.load(path) {
374 Ok(Some(data)) => db.load_font_data(data.into_owned()),
375 Ok(None) => log::warn!("Bundled font not found: {path}"),
376 Err(error) => log::warn!("Failed to load bundled font {path}: {error}"),
377 }
378 }
379}
380
381fn fix_generic_font_families(db: &mut usvg::fontdb::Database) {
385 use usvg::fontdb::{Family, Query};
386
387 let families_and_fallbacks: &[(Family<'_>, &str)] = &[
388 (Family::SansSerif, "IBM Plex Sans"),
389 (Family::Serif, "IBM Plex Sans"),
391 (Family::Monospace, "Lilex"),
392 (Family::Cursive, "IBM Plex Sans"),
393 (Family::Fantasy, "IBM Plex Sans"),
394 ];
395
396 for (family, fallback_name) in families_and_fallbacks {
397 let query = Query {
398 families: &[*family],
399 ..Default::default()
400 };
401 if db.query(&query).is_none() {
402 match family {
403 Family::SansSerif => db.set_sans_serif_family(*fallback_name),
404 Family::Serif => db.set_serif_family(*fallback_name),
405 Family::Monospace => db.set_monospace_family(*fallback_name),
406 Family::Cursive => db.set_cursive_family(*fallback_name),
407 Family::Fantasy => db.set_fantasy_family(*fallback_name),
408 _ => {}
409 }
410 }
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use usvg::fontdb::{Database, Family, Query};
418
419 const IBM_PLEX_REGULAR: &[u8] =
420 include_bytes!("../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
421 const LILEX_REGULAR: &[u8] = include_bytes!("../assets/fonts/lilex/Lilex-Regular.ttf");
422
423 #[test]
424 fn renders_parsed_svg_at_requested_size() -> Result<()> {
425 let renderer = SvgRenderer::new(Arc::new(()));
426 let svg = renderer.parse_svg(
427 br#"<svg xmlns="http://www.w3.org/2000/svg" width="24pt" height="12pt"></svg>"#,
428 )?;
429 let requested_size = Size::new(DevicePixels(24), DevicePixels(12));
430 let image = renderer.render_parsed(&svg, SvgSize::ExactSize(requested_size))?;
431
432 assert_eq!(image.size(0), requested_size);
433 Ok(())
434 }
435
436 #[test]
437 fn preserves_aspect_ratio_for_width_constrained_size() -> Result<()> {
438 let renderer = SvgRenderer::new(Arc::new(()));
439 let svg = renderer.parse_svg(
440 br#"<svg xmlns="http://www.w3.org/2000/svg" width="24pt" height="12pt"></svg>"#,
441 )?;
442 let image = renderer.render_parsed(
443 &svg,
444 SvgSize::Size(Size::new(DevicePixels(24), DevicePixels(24))),
445 )?;
446
447 assert_eq!(image.size(0), Size::new(DevicePixels(24), DevicePixels(12)));
448 Ok(())
449 }
450
451 fn db_with_bundled_fonts() -> Database {
452 let mut db = Database::new();
453 db.load_font_data(IBM_PLEX_REGULAR.to_vec());
454 db.load_font_data(LILEX_REGULAR.to_vec());
455 db
456 }
457
458 #[test]
459 fn text_with_split_glyph_clusters_in_mixed_fonts_does_not_panic() {
460 let mut db = Database::new();
461 db.load_font_data(IBM_PLEX_REGULAR.to_vec());
462 db.load_font_data(LILEX_REGULAR.to_vec());
463 let options = usvg::Options {
464 fontdb: std::sync::Arc::new(db),
465 ..Default::default()
466 };
467
468 let zalgo = "e\u{0301}\u{0302}\u{0303}\u{0304}\u{0306}\u{0307}\u{0308}\u{030a}";
473 let svg = format!(
474 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>"#
475 );
476
477 usvg::Tree::from_data(svg.as_bytes(), &options)
480 .expect("SVG with mixed-font text should parse");
481 }
482
483 #[test]
484 fn test_is_emoji_presentation() {
485 let cases = [
486 ("a", false),
487 ("Z", false),
488 ("1", false),
489 ("#", false),
490 ("*", false),
491 ("漢", false),
492 ("中", false),
493 ("カ", false),
494 ("©", false),
495 ("♥", false),
496 ("😀", true),
497 ("✅", true),
498 ("🇺🇸", true),
499 ("©️", false),
501 ("♥️", false),
502 ("1️⃣", false),
503 ];
504 for (s, expected) in cases {
505 assert_eq!(
506 is_emoji_presentation(s.chars().next().unwrap()),
507 expected,
508 "for char {:?}",
509 s
510 );
511 }
512 }
513
514 #[test]
515 fn fix_generic_font_families_sets_all_families() {
516 let mut db = db_with_bundled_fonts();
517 fix_generic_font_families(&mut db);
518
519 let families = [
520 Family::SansSerif,
521 Family::Serif,
522 Family::Monospace,
523 Family::Cursive,
524 Family::Fantasy,
525 ];
526
527 for family in families {
528 let query = Query {
529 families: &[family],
530 ..Default::default()
531 };
532 assert!(
533 db.query(&query).is_some(),
534 "Expected generic family {family:?} to resolve after fix_generic_font_families"
535 );
536 }
537 }
538
539 #[test]
540 fn test_select_emoji_font_skips_family_without_glyph() {
541 let mut db = db_with_bundled_fonts();
542
543 let ibm_plex_sans = db
544 .query(&usvg::fontdb::Query {
545 families: &[usvg::fontdb::Family::Name("IBM Plex Sans")],
546 weight: usvg::fontdb::Weight(400),
547 stretch: usvg::fontdb::Stretch::Normal,
548 style: usvg::fontdb::Style::Normal,
549 })
550 .unwrap();
551 let lilex = db
552 .query(&usvg::fontdb::Query {
553 families: &[usvg::fontdb::Family::Name("Lilex")],
554 weight: usvg::fontdb::Weight(400),
555 stretch: usvg::fontdb::Stretch::Normal,
556 style: usvg::fontdb::Style::Normal,
557 })
558 .unwrap();
559 let selected = select_emoji_font('│', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap();
560
561 assert_eq!(selected, lilex);
562 assert!(!font_has_char(&db, ibm_plex_sans, '│'));
563 assert!(font_has_char(&db, selected, '│'));
564 }
565
566 #[test]
567 fn fix_generic_font_families_monospace_resolves_to_lilex() {
568 let mut db = db_with_bundled_fonts();
569 fix_generic_font_families(&mut db);
570
571 let query = Query {
572 families: &[Family::Monospace],
573 ..Default::default()
574 };
575 let id = db.query(&query).expect("Monospace should resolve");
576 let face = db.face(id).expect("Face should exist");
577 assert!(
578 face.families.iter().any(|(name, _)| name.contains("Lilex")),
579 "Monospace should map to Lilex, got {:?}",
580 face.families
581 );
582 }
583}