Skip to main content

gpui/
svg_renderer.rs

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
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/// A parsed SVG document that can be rasterized at any scale.
98///
99/// Produced by [`SvgRenderer::parse_svg`] and rasterized by
100/// [`SvgRenderer::render_parsed`]. Parsing resolves fonts and converts text
101/// to paths, so callers that need to rasterize the same SVG at multiple
102/// scales should retain this value to avoid re-paying the parse cost.
103pub struct ParsedSvg(usvg::Tree);
104
105impl ParsedSvg {
106    /// The document's intrinsic size in SVG user units, after resolving its viewport.
107    pub fn size(&self) -> Size<f32> {
108        let size = self.0.size();
109        Size::new(size.width(), size.height())
110    }
111}
112
113/// The size in which to rasterize the SVG.
114#[derive(Clone, Copy)]
115pub enum SvgSize {
116    /// A width in device pixels. The SVG retains its aspect ratio.
117    Size(Size<DevicePixels>),
118    /// An exact width and height in device pixels.
119    ExactSize(Size<DevicePixels>),
120    /// Rasterize a rectangle in resolved document coordinates into an exact output.
121    /// Unlike whole-document sizing, zoom does not enlarge the output allocation.
122    /// Filter intermediates are still controlled by resvg, not this output size.
123    Viewport {
124        /// Exact output dimensions; each edge must be 1..=8192 device pixels.
125        size: Size<DevicePixels>,
126        /// Visible rectangle in resolved SVG document coordinates.
127        view_box: Bounds<f32>,
128    },
129    /// A logical scaling factor to apply to the size provided by the SVG.
130    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    /// Creates a new SVG renderer with the provided asset source.
141    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        // Build the enriched font DB lazily on first SVG render rather than
149        // eagerly at construction time. This avoids the expensive deep-clone
150        // of the system font database for code paths that never render SVGs
151        // (e.g. tests).
152        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                // fontdb doesn't recognize CSS system font keywords like "system-ui"
171                // or "ui-sans-serif", so fall back to sans-serif before any face.
172                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    /// Parses SVG data into a [`ParsedSvg`] that can be rasterized at any scale.
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 previously parsed SVG into an image buffer.
212    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    /// Renders the given bytes into an image buffer.
240    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            // Convert the pixmap's pixels into an alpha mask.
260            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(&params.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    // Cap the size of the rendered pixmap to avoid texture allocation panics
290    // Related issue: #56466
291    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    // Render the SVG to a pixmap with the specified width and height.
354    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
381// fontdb defaults generic families to Microsoft fonts ("Arial", "Times New Roman")
382// which aren't installed on most Linux systems. fontconfig normally overrides these,
383// but when it fails the defaults remain and all generic family queries return None.
384fn 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        // No serif font bundled; use sans-serif as best available fallback.
390        (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        // A base letter followed by a stack of combining marks. Under HarfBuzz's
469        // default cluster merging every mark glyph shares the base's byte index,
470        // which is the "glyph splitting" condition that triggered the panic. The
471        // chunk must use two different fonts so the buggy merge path runs.
472        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        // Before the fix this aborts via panic with a message like
478        // "removal index (is 5) should be < len (is 5)".
479        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            // SVG fallback is not cluster-aware yet
500            ("©️", 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}