Skip to main content

gpui/
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 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
81/// When rendering SVGs, we render them at twice the size to get a higher-quality result.
82pub 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)]
92/// A struct holding everything necessary to render SVGs.
93pub struct SvgRenderer {
94    asset_source: Arc<dyn AssetSource>,
95    usvg_options: Arc<usvg::Options<'static>>,
96}
97
98/// The size in which to render the SVG.
99pub enum SvgSize {
100    /// An absolute size in device pixels.
101    Size(Size<DevicePixels>),
102    /// A scaling factor to apply to the size provided by the SVG.
103    ScaleFactor(f32),
104}
105
106impl SvgRenderer {
107    /// Creates a new SVG renderer with the provided asset source.
108    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        // Build the enriched font DB lazily on first SVG render rather than
116        // eagerly at construction time. This avoids the expensive deep-clone
117        // of the system font database for code paths that never render SVGs
118        // (e.g. tests).
119        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                // fontdb doesn't recognize CSS system font keywords like "system-ui"
138                // or "ui-sans-serif", so fall back to sans-serif before any face.
139                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    /// Renders the given bytes into an image buffer.
174    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            // Convert the pixmap's pixels into an alpha mask.
209            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(&params.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        // Cap the size of the rendered pixmap to avoid texture allocation panics
233        // Related issue: #56466
234        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        // Render the SVG to a pixmap with the specified width and height.
257        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
285// fontdb defaults generic families to Microsoft fonts ("Arial", "Times New Roman")
286// which aren't installed on most Linux systems. fontconfig normally overrides these,
287// but when it fails the defaults remain and all generic family queries return None.
288fn 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        // No serif font bundled; use sans-serif as best available fallback.
294        (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        // A base letter followed by a stack of combining marks. Under HarfBuzz's
345        // default cluster merging every mark glyph shares the base's byte index,
346        // which is the "glyph splitting" condition that triggered the panic. The
347        // chunk must use two different fonts so the buggy merge path runs.
348        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        // Before the fix this aborts via panic with a message like
354        // "removal index (is 5) should be < len (is 5)".
355        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            // SVG fallback is not cluster-aware yet
376            ("©️", 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}