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