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(&params.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        let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
232        let svg_size = tree.size();
233        let scale = match size {
234            SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(),
235            SvgSize::ScaleFactor(scale) => scale,
236        };
237
238        // Render the SVG to a pixmap with the specified width and height.
239        let mut pixmap = resvg::tiny_skia::Pixmap::new(
240            (svg_size.width() * scale) as u32,
241            (svg_size.height() * scale) as u32,
242        )
243        .ok_or(usvg::Error::InvalidSize)?;
244
245        let transform = resvg::tiny_skia::Transform::from_scale(scale, scale);
246
247        resvg::render(&tree, transform, &mut pixmap.as_mut());
248
249        Ok(pixmap)
250    }
251}
252
253fn load_bundled_fonts(asset_source: &dyn AssetSource, db: &mut usvg::fontdb::Database) {
254    let font_paths = [
255        "fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
256        "fonts/lilex/Lilex-Regular.ttf",
257    ];
258    for path in font_paths {
259        match asset_source.load(path) {
260            Ok(Some(data)) => db.load_font_data(data.into_owned()),
261            Ok(None) => log::warn!("Bundled font not found: {path}"),
262            Err(error) => log::warn!("Failed to load bundled font {path}: {error}"),
263        }
264    }
265}
266
267// fontdb defaults generic families to Microsoft fonts ("Arial", "Times New Roman")
268// which aren't installed on most Linux systems. fontconfig normally overrides these,
269// but when it fails the defaults remain and all generic family queries return None.
270fn fix_generic_font_families(db: &mut usvg::fontdb::Database) {
271    use usvg::fontdb::{Family, Query};
272
273    let families_and_fallbacks: &[(Family<'_>, &str)] = &[
274        (Family::SansSerif, "IBM Plex Sans"),
275        // No serif font bundled; use sans-serif as best available fallback.
276        (Family::Serif, "IBM Plex Sans"),
277        (Family::Monospace, "Lilex"),
278        (Family::Cursive, "IBM Plex Sans"),
279        (Family::Fantasy, "IBM Plex Sans"),
280    ];
281
282    for (family, fallback_name) in families_and_fallbacks {
283        let query = Query {
284            families: &[*family],
285            ..Default::default()
286        };
287        if db.query(&query).is_none() {
288            match family {
289                Family::SansSerif => db.set_sans_serif_family(*fallback_name),
290                Family::Serif => db.set_serif_family(*fallback_name),
291                Family::Monospace => db.set_monospace_family(*fallback_name),
292                Family::Cursive => db.set_cursive_family(*fallback_name),
293                Family::Fantasy => db.set_fantasy_family(*fallback_name),
294                _ => {}
295            }
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use usvg::fontdb::{Database, Family, Query};
304
305    const IBM_PLEX_REGULAR: &[u8] =
306        include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
307    const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf");
308
309    fn db_with_bundled_fonts() -> Database {
310        let mut db = Database::new();
311        db.load_font_data(IBM_PLEX_REGULAR.to_vec());
312        db.load_font_data(LILEX_REGULAR.to_vec());
313        db
314    }
315
316    #[test]
317    fn test_is_emoji_presentation() {
318        let cases = [
319            ("a", false),
320            ("Z", false),
321            ("1", false),
322            ("#", false),
323            ("*", false),
324            ("ζΌ’", false),
325            ("δΈ­", false),
326            ("γ‚«", false),
327            ("Β©", false),
328            ("β™₯", false),
329            ("πŸ˜€", true),
330            ("βœ…", true),
331            ("πŸ‡ΊπŸ‡Έ", true),
332            // SVG fallback is not cluster-aware yet
333            ("©️", false),
334            ("β™₯️", false),
335            ("1️⃣", false),
336        ];
337        for (s, expected) in cases {
338            assert_eq!(
339                is_emoji_presentation(s.chars().next().unwrap()),
340                expected,
341                "for char {:?}",
342                s
343            );
344        }
345    }
346
347    #[test]
348    fn fix_generic_font_families_sets_all_families() {
349        let mut db = db_with_bundled_fonts();
350        fix_generic_font_families(&mut db);
351
352        let families = [
353            Family::SansSerif,
354            Family::Serif,
355            Family::Monospace,
356            Family::Cursive,
357            Family::Fantasy,
358        ];
359
360        for family in families {
361            let query = Query {
362                families: &[family],
363                ..Default::default()
364            };
365            assert!(
366                db.query(&query).is_some(),
367                "Expected generic family {family:?} to resolve after fix_generic_font_families"
368            );
369        }
370    }
371
372    #[test]
373    fn test_select_emoji_font_skips_family_without_glyph() {
374        let mut db = db_with_bundled_fonts();
375
376        let ibm_plex_sans = db
377            .query(&usvg::fontdb::Query {
378                families: &[usvg::fontdb::Family::Name("IBM Plex Sans")],
379                weight: usvg::fontdb::Weight(400),
380                stretch: usvg::fontdb::Stretch::Normal,
381                style: usvg::fontdb::Style::Normal,
382            })
383            .unwrap();
384        let lilex = db
385            .query(&usvg::fontdb::Query {
386                families: &[usvg::fontdb::Family::Name("Lilex")],
387                weight: usvg::fontdb::Weight(400),
388                stretch: usvg::fontdb::Stretch::Normal,
389                style: usvg::fontdb::Style::Normal,
390            })
391            .unwrap();
392        let selected = select_emoji_font('β”‚', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap();
393
394        assert_eq!(selected, lilex);
395        assert!(!font_has_char(&db, ibm_plex_sans, 'β”‚'));
396        assert!(font_has_char(&db, selected, 'β”‚'));
397    }
398
399    #[test]
400    fn fix_generic_font_families_monospace_resolves_to_lilex() {
401        let mut db = db_with_bundled_fonts();
402        fix_generic_font_families(&mut db);
403
404        let query = Query {
405            families: &[Family::Monospace],
406            ..Default::default()
407        };
408        let id = db.query(&query).expect("Monospace should resolve");
409        let face = db.face(id).expect("Face should exist");
410        assert!(
411            face.families.iter().any(|(name, _)| name.contains("Lilex")),
412            "Monospace should map to Lilex, got {:?}",
413            face.families
414        );
415    }
416}