Skip to main content

gpui/
svg_renderer.rs

1use crate::{
2    AssetRegistry, 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/// The multiplier used when [`SvgRenderer::render_parsed`] rasterizes a scale-factor request.
82///
83/// The SVG is drawn at this multiple of the requested size and tagged with the same factor. It is
84/// then displayed at the requested logical size, with smoother edges.
85pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.;
86
87#[derive(Clone, PartialEq, Hash, Eq)]
88#[expect(missing_docs)]
89pub struct RenderSvgParams {
90    pub path: SharedString,
91    pub size: Size<DevicePixels>,
92}
93
94/// Parses and rasterizes SVG documents, resolving their fonts from the app's [`crate::AssetRegistry`].
95///
96/// The app holds one, and [`crate::App::svg_renderer`] returns it. The renderer looks for
97/// `fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf` and `fonts/lilex/Lilex-Regular.ttf` in the registry
98/// and loads each as a font. It logs a warning when one is missing, and falls back to system emoji
99/// fonts for emoji.
100#[derive(Clone)]
101pub struct SvgRenderer {
102    asset_registry: Arc<AssetRegistry>,
103    usvg_options: Arc<usvg::Options<'static>>,
104}
105
106/// A parsed SVG document that can be rasterized at any scale.
107///
108/// Produced by [`SvgRenderer::parse_svg`] and rasterized by
109/// [`SvgRenderer::render_parsed`]. Parsing resolves fonts and converts text to paths. Retain this
110/// value when the same SVG will be rasterized at several scales so that the parse cost is paid
111/// only once.
112pub struct ParsedSvg(usvg::Tree);
113
114/// The size at which [`SvgRenderer::render_parsed`] rasterizes an SVG.
115#[derive(Clone, Copy)]
116pub enum SvgSize {
117    /// A width in device pixels. The height follows the SVG's aspect ratio.
118    Size(Size<DevicePixels>),
119    /// An exact width and height in device pixels. The SVG is stretched if the ratio differs.
120    ExactSize(Size<DevicePixels>),
121    /// A multiple of the SVG's own size, in logical pixels. `1.0` is the natural size.
122    ScaleFactor(f32),
123}
124
125impl From<f32> for SvgSize {
126    fn from(scale_factor: f32) -> Self {
127        Self::ScaleFactor(scale_factor)
128    }
129}
130
131impl SvgRenderer {
132    /// Creates a renderer that loads fonts and files from `asset_registry`.
133    ///
134    /// [`crate::Application::with_assets`] creates one for you. System fonts are scanned once per process,
135    /// the first time a font is needed.
136    pub fn new(asset_registry: Arc<AssetRegistry>) -> Self {
137        static SYSTEM_FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
138            let mut db = usvg::fontdb::Database::new();
139            db.load_system_fonts();
140            Arc::new(db)
141        });
142
143        // Build the enriched font DB lazily on first SVG render rather than
144        // eagerly at construction time. This avoids the expensive deep-clone
145        // of the system font database for code paths that never render SVGs
146        // (e.g. tests).
147        let enriched_fontdb: Arc<OnceLock<Arc<usvg::fontdb::Database>>> = Arc::new(OnceLock::new());
148
149        let default_font_resolver = usvg::FontResolver::default_font_selector();
150        let font_resolver = Box::new({
151            let asset_database = asset_registry.clone();
152            move |font: &usvg::Font, db: &mut Arc<usvg::fontdb::Database>| {
153                if db.is_empty() {
154                    let fontdb = enriched_fontdb.get_or_init(|| {
155                        let mut db = (**SYSTEM_FONT_DB).clone();
156                        load_bundled_fonts(&asset_database, &mut db);
157                        fix_generic_font_families(&mut db);
158                        Arc::new(db)
159                    });
160                    *db = fontdb.clone();
161                }
162                if let Some(id) = default_font_resolver(font, db) {
163                    return Some(id);
164                }
165                // fontdb doesn't recognize CSS system font keywords like "system-ui"
166                // or "ui-sans-serif", so fall back to sans-serif before any face.
167                let sans_query = usvg::fontdb::Query {
168                    families: &[usvg::fontdb::Family::SansSerif],
169                    ..Default::default()
170                };
171                db.query(&sans_query)
172                    .or_else(|| db.faces().next().map(|f| f.id))
173            }
174        });
175        let default_fallback_selection = usvg::FontResolver::default_fallback_selector();
176        let fallback_selection = Box::new(
177            move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc<usvg::fontdb::Database>| {
178                if is_emoji_presentation(ch) {
179                    if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES)
180                    {
181                        return Some(id);
182                    }
183                }
184
185                default_fallback_selection(ch, fonts, db)
186            },
187        );
188        let options = usvg::Options {
189            font_resolver: usvg::FontResolver {
190                select_font: font_resolver,
191                select_fallback: fallback_selection,
192            },
193            ..Default::default()
194        };
195        Self {
196            asset_registry,
197            usvg_options: Arc::new(options),
198        }
199    }
200
201    /// Parses SVG bytes into a [`ParsedSvg`], which can then be rasterized at any size.
202    ///
203    /// # Errors
204    ///
205    /// Returns a `usvg::Error` if `bytes` is not a valid SVG document.
206    #[tracing::instrument(skip_all)]
207    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    /// Rasterizes a parsed SVG at `size` into a [`RenderImage`].
212    ///
213    /// A scale-factor size is drawn at [`SMOOTH_SVG_SCALE_FACTOR`] times the requested size, and the
214    /// image is marked so that it displays at the logical size. `Size` and `ExactSize` use device
215    /// pixels and are not scaled further. A result larger than 8192 pixels on either side is scaled
216    /// down to fit.
217    ///
218    /// # Errors
219    ///
220    /// Returns a `usvg::Error` if the rasterized size is zero, such as for a zero requested size.
221    #[tracing::instrument(skip_all)]
222    pub fn render_parsed(
223        &self,
224        svg: &ParsedSvg,
225        size: impl Into<SvgSize>,
226    ) -> Result<Arc<RenderImage>, usvg::Error> {
227        let (size, image_scale_factor) = match size.into() {
228            SvgSize::Size(size) => (SvgSize::Size(size), 1.0),
229            SvgSize::ExactSize(size) => (SvgSize::ExactSize(size), 1.0),
230            SvgSize::ScaleFactor(scale_factor) => (
231                SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR),
232                SMOOTH_SVG_SCALE_FACTOR,
233            ),
234        };
235        let pixmap = rasterize_tree(&svg.0, size)?;
236        let mut buffer =
237            image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap();
238
239        for pixel in buffer.chunks_exact_mut(4) {
240            swap_rgba_pa_to_bgra(pixel);
241        }
242
243        let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)]));
244        image.scale_factor = image_scale_factor;
245        Ok(Arc::new(image))
246    }
247
248    /// Parses and rasterizes `bytes` at a logical `scale_factor`, in one call.
249    ///
250    /// This is [`SvgRenderer::parse_svg`] followed by [`SvgRenderer::render_parsed`] with
251    /// [`SvgSize::ScaleFactor`]. If you need the same SVG at several sizes, parse it once and call
252    /// `render_parsed` for each size.
253    ///
254    /// # Errors
255    ///
256    /// Returns a `usvg::Error` if `bytes` is not a valid SVG, or if the rasterized size is zero.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// # use gpui::*;
262    /// # use std::sync::Arc;
263    /// let renderer = SvgRenderer::new(Arc::new(AssetRegistry::default()));
264    /// let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="12"></svg>"#;
265    /// let image = renderer.render_single_frame(svg, 1.0).unwrap();
266    ///
267    /// assert_eq!(image.frame_count(), 1);
268    /// ```
269    pub fn render_single_frame(
270        &self,
271        bytes: &[u8],
272        scale_factor: f32,
273    ) -> Result<Arc<RenderImage>, usvg::Error> {
274        let svg = self.parse_svg(bytes)?;
275        self.render_parsed(&svg, scale_factor)
276    }
277
278    pub(crate) fn render_alpha_mask(
279        &self,
280        params: &RenderSvgParams,
281        bytes: Option<&[u8]>,
282    ) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
283        anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
284
285        let render_pixmap = |bytes| {
286            let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?;
287
288            // Convert the pixmap's pixels into an alpha mask.
289            let size = Size::new(
290                DevicePixels(pixmap.width() as i32),
291                DevicePixels(pixmap.height() as i32),
292            );
293            let alpha_mask = pixmap
294                .pixels()
295                .iter()
296                .map(|p| p.alpha())
297                .collect::<Vec<_>>();
298
299            Ok(Some((size, alpha_mask)))
300        };
301
302        if let Some(bytes) = bytes {
303            render_pixmap(bytes)
304        } else if let Some(bytes) = self.asset_registry.load(&params.path) {
305            render_pixmap(&bytes)
306        } else {
307            Ok(None)
308        }
309    }
310
311    fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
312        let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
313        rasterize_tree(&tree, size)
314    }
315}
316
317fn rasterize_tree(tree: &usvg::Tree, size: SvgSize) -> Result<Pixmap, usvg::Error> {
318    // Cap the size of the rendered pixmap to avoid texture allocation panics
319    // Related issue: #56466
320    const MAX_SIZE: f32 = 8192.0;
321
322    let svg_size = tree.size();
323    let (mut width, mut height) = match size {
324        SvgSize::Size(size) => {
325            let scale = i32::from(size.width) as f32 / svg_size.width();
326            (svg_size.width() * scale, svg_size.height() * scale)
327        }
328        SvgSize::ExactSize(size) => (i32::from(size.width) as f32, i32::from(size.height) as f32),
329        SvgSize::ScaleFactor(scale) => (svg_size.width() * scale, svg_size.height() * scale),
330    };
331
332    if width > MAX_SIZE {
333        log::warn!("Attempted to render pixmap where width ({width}) > MAX_SIZE ({MAX_SIZE})");
334    }
335    if height > MAX_SIZE {
336        log::warn!("Attempted to render pixmap where height ({height}) > MAX_SIZE ({MAX_SIZE})");
337    }
338    let scale = (MAX_SIZE / width).min(MAX_SIZE / height).min(1.0);
339    width *= scale;
340    height *= scale;
341
342    // Render the SVG to a pixmap with the specified width and height.
343    let mut pixmap = resvg::tiny_skia::Pixmap::new(width as u32, height as u32)
344        .ok_or(usvg::Error::InvalidSize)?;
345
346    let transform = resvg::tiny_skia::Transform::from_scale(
347        width / svg_size.width(),
348        height / svg_size.height(),
349    );
350
351    resvg::render(tree, transform, &mut pixmap.as_mut());
352
353    Ok(pixmap)
354}
355
356fn load_bundled_fonts(asset_database: &AssetRegistry, db: &mut usvg::fontdb::Database) {
357    let font_paths = [
358        "fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
359        "fonts/lilex/Lilex-Regular.ttf",
360    ];
361    for path in font_paths {
362        match asset_database.load(path) {
363            Some(data) => db.load_font_data(data.into_owned()),
364            None => log::warn!("Bundled font not found: {path}"),
365        }
366    }
367}
368
369// fontdb defaults generic families to Microsoft fonts ("Arial", "Times New Roman")
370// which aren't installed on most Linux systems. fontconfig normally overrides these,
371// but when it fails the defaults remain and all generic family queries return None.
372fn fix_generic_font_families(db: &mut usvg::fontdb::Database) {
373    use usvg::fontdb::{Family, Query};
374
375    let families_and_fallbacks: &[(Family<'_>, &str)] = &[
376        (Family::SansSerif, "IBM Plex Sans"),
377        // No serif font bundled; use sans-serif as best available fallback.
378        (Family::Serif, "IBM Plex Sans"),
379        (Family::Monospace, "Lilex"),
380        (Family::Cursive, "IBM Plex Sans"),
381        (Family::Fantasy, "IBM Plex Sans"),
382    ];
383
384    for (family, fallback_name) in families_and_fallbacks {
385        let query = Query {
386            families: &[*family],
387            ..Default::default()
388        };
389        if db.query(&query).is_none() {
390            match family {
391                Family::SansSerif => db.set_sans_serif_family(*fallback_name),
392                Family::Serif => db.set_serif_family(*fallback_name),
393                Family::Monospace => db.set_monospace_family(*fallback_name),
394                Family::Cursive => db.set_cursive_family(*fallback_name),
395                Family::Fantasy => db.set_fantasy_family(*fallback_name),
396                _ => {}
397            }
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use usvg::fontdb::{Database, Family, Query};
406
407    #[test]
408    fn renders_parsed_svg_at_requested_size() -> Result<()> {
409        let renderer = SvgRenderer::new(Arc::new(AssetRegistry::default()));
410        let svg = renderer.parse_svg(
411            br#"<svg xmlns="http://www.w3.org/2000/svg" width="24pt" height="12pt"></svg>"#,
412        )?;
413        let requested_size = Size::new(DevicePixels(24), DevicePixels(12));
414        let image = renderer.render_parsed(&svg, SvgSize::ExactSize(requested_size))?;
415
416        assert_eq!(image.size(0), requested_size);
417        Ok(())
418    }
419
420    #[test]
421    fn preserves_aspect_ratio_for_width_constrained_size() -> Result<()> {
422        let renderer = SvgRenderer::new(Arc::new(AssetRegistry::default()));
423        let svg = renderer.parse_svg(
424            br#"<svg xmlns="http://www.w3.org/2000/svg" width="24pt" height="12pt"></svg>"#,
425        )?;
426        let image = renderer.render_parsed(
427            &svg,
428            SvgSize::Size(Size::new(DevicePixels(24), DevicePixels(24))),
429        )?;
430
431        assert_eq!(image.size(0), Size::new(DevicePixels(24), DevicePixels(12)));
432        Ok(())
433    }
434
435    fn db_with_bundled_fonts() -> Database {
436        let mut db = Database::new();
437        db.load_font_data(gpui_fonts::IBM_PLEX.to_vec());
438        db.load_font_data(gpui_fonts::LILEX.to_vec());
439        db
440    }
441
442    #[test]
443    fn text_with_split_glyph_clusters_in_mixed_fonts_does_not_panic() {
444        let mut db = Database::new();
445        db.load_font_data(gpui_fonts::IBM_PLEX.to_vec());
446        db.load_font_data(gpui_fonts::LILEX.to_vec());
447        let options = usvg::Options {
448            fontdb: std::sync::Arc::new(db),
449            ..Default::default()
450        };
451
452        // A base letter followed by a stack of combining marks. Under HarfBuzz's
453        // default cluster merging every mark glyph shares the base's byte index,
454        // which is the "glyph splitting" condition that triggered the panic. The
455        // chunk must use two different fonts so the buggy merge path runs.
456        let zalgo = "e\u{0301}\u{0302}\u{0303}\u{0304}\u{0306}\u{0307}\u{0308}\u{030a}";
457        let svg = format!(
458            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>"#
459        );
460
461        // Before the fix this aborts via panic with a message like
462        // "removal index (is 5) should be < len (is 5)".
463        usvg::Tree::from_data(svg.as_bytes(), &options)
464            .expect("SVG with mixed-font text should parse");
465    }
466
467    #[test]
468    fn test_is_emoji_presentation() {
469        let cases = [
470            ("a", false),
471            ("Z", false),
472            ("1", false),
473            ("#", false),
474            ("*", false),
475            ("漢", false),
476            ("中", false),
477            ("カ", false),
478            ("©", false),
479            ("♥", false),
480            ("😀", true),
481            ("✅", true),
482            ("🇺🇸", true),
483            // SVG fallback is not cluster-aware yet
484            ("©️", false),
485            ("♥️", false),
486            ("1️⃣", false),
487        ];
488        for (s, expected) in cases {
489            assert_eq!(
490                is_emoji_presentation(s.chars().next().unwrap()),
491                expected,
492                "for char {:?}",
493                s
494            );
495        }
496    }
497
498    #[test]
499    fn fix_generic_font_families_sets_all_families() {
500        let mut db = db_with_bundled_fonts();
501        fix_generic_font_families(&mut db);
502
503        let families = [
504            Family::SansSerif,
505            Family::Serif,
506            Family::Monospace,
507            Family::Cursive,
508            Family::Fantasy,
509        ];
510
511        for family in families {
512            let query = Query {
513                families: &[family],
514                ..Default::default()
515            };
516            assert!(
517                db.query(&query).is_some(),
518                "Expected generic family {family:?} to resolve after fix_generic_font_families"
519            );
520        }
521    }
522
523    #[test]
524    fn test_select_emoji_font_skips_family_without_glyph() {
525        let mut db = db_with_bundled_fonts();
526
527        let ibm_plex_sans = db
528            .query(&usvg::fontdb::Query {
529                families: &[usvg::fontdb::Family::Name("IBM Plex Sans")],
530                weight: usvg::fontdb::Weight(400),
531                stretch: usvg::fontdb::Stretch::Normal,
532                style: usvg::fontdb::Style::Normal,
533            })
534            .unwrap();
535        let lilex = db
536            .query(&usvg::fontdb::Query {
537                families: &[usvg::fontdb::Family::Name("Lilex")],
538                weight: usvg::fontdb::Weight(400),
539                stretch: usvg::fontdb::Stretch::Normal,
540                style: usvg::fontdb::Style::Normal,
541            })
542            .unwrap();
543        let selected = select_emoji_font('│', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap();
544
545        assert_eq!(selected, lilex);
546        assert!(!font_has_char(&db, ibm_plex_sans, '│'));
547        assert!(font_has_char(&db, selected, '│'));
548    }
549
550    #[test]
551    fn fix_generic_font_families_monospace_resolves_to_lilex() {
552        let mut db = db_with_bundled_fonts();
553        fix_generic_font_families(&mut db);
554
555        let query = Query {
556            families: &[Family::Monospace],
557            ..Default::default()
558        };
559        let id = db.query(&query).expect("Monospace should resolve");
560        let face = db.face(id).expect("Face should exist");
561        assert!(
562            face.families.iter().any(|(name, _)| name.contains("Lilex")),
563            "Monospace should map to Lilex, got {:?}",
564            face.families
565        );
566    }
567}