Skip to main content

gpui_authoring/
svg_renderer.rs

1use crate::{
2    AssetSource, DevicePixels, IsZero, RenderImage, RenderSvgParams, Result, Size,
3    swap_rgba_pa_to_bgra,
4};
5use image::Frame;
6use resvg::tiny_skia::Pixmap;
7use smallvec::SmallVec;
8use std::sync::{Arc, LazyLock, OnceLock};
9
10#[cfg(target_os = "macos")]
11const EMOJI_FONT_FAMILIES: &[&str] = &["Apple Color Emoji", ".AppleColorEmojiUI"];
12
13#[cfg(target_os = "windows")]
14const EMOJI_FONT_FAMILIES: &[&str] = &["Segoe UI Emoji", "Segoe UI Symbol"];
15
16#[cfg(any(target_os = "linux", target_os = "freebsd"))]
17const EMOJI_FONT_FAMILIES: &[&str] = &[
18    "Noto Color Emoji",
19    "Emoji One",
20    "Twitter Color Emoji",
21    "JoyPixels",
22];
23
24#[cfg(not(any(
25    target_os = "macos",
26    target_os = "windows",
27    target_os = "linux",
28    target_os = "freebsd",
29)))]
30const EMOJI_FONT_FAMILIES: &[&str] = &[];
31
32fn is_emoji_presentation(c: char) -> bool {
33    static EMOJI_PRESENTATION_REGEX: LazyLock<regex::Regex> =
34        LazyLock::new(|| regex::Regex::new("\\p{Emoji_Presentation}").unwrap());
35    let mut buf = [0u8; 4];
36    EMOJI_PRESENTATION_REGEX.is_match(c.encode_utf8(&mut buf))
37}
38
39fn font_has_char(db: &usvg::fontdb::Database, id: usvg::fontdb::ID, ch: char) -> bool {
40    db.with_face_data(id, |font_data, face_index| {
41        ttf_parser::Face::parse(font_data, face_index)
42            .ok()
43            .and_then(|face| face.glyph_index(ch))
44            .is_some()
45    })
46    .unwrap_or(false)
47}
48
49fn select_emoji_font(
50    ch: char,
51    fonts: &[usvg::fontdb::ID],
52    db: &usvg::fontdb::Database,
53    families: &[&str],
54) -> Option<usvg::fontdb::ID> {
55    for family_name in families {
56        let query = usvg::fontdb::Query {
57            families: &[usvg::fontdb::Family::Name(family_name)],
58            weight: usvg::fontdb::Weight(400),
59            stretch: usvg::fontdb::Stretch::Normal,
60            style: usvg::fontdb::Style::Normal,
61        };
62
63        let Some(id) = db.query(&query) else {
64            continue;
65        };
66
67        if fonts.contains(&id) || !font_has_char(db, id, ch) {
68            continue;
69        }
70
71        return Some(id);
72    }
73
74    None
75}
76
77/// When rendering SVGs, we render them at twice the size to get a higher-quality result.
78pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.;
79
80#[derive(Clone)]
81/// A struct holding everything necessary to render SVGs.
82pub struct SvgRenderer {
83    asset_source: Arc<dyn AssetSource>,
84    usvg_options: Arc<usvg::Options<'static>>,
85}
86
87/// A parsed SVG document that can be rasterized at any scale.
88///
89/// Produced by [`SvgRenderer::parse_svg`] and rasterized by
90/// [`SvgRenderer::render_parsed`]. Parsing resolves fonts and converts text
91/// to paths, so callers that need to rasterize the same SVG at multiple
92/// scales should retain this value to avoid re-paying the parse cost.
93pub struct ParsedSvg(usvg::Tree);
94
95/// The size in which to rasterize the SVG.
96#[derive(Clone, Copy)]
97pub enum SvgSize {
98    /// A width in device pixels. The SVG retains its aspect ratio.
99    Size(Size<DevicePixels>),
100    /// An exact width and height in device pixels.
101    ExactSize(Size<DevicePixels>),
102    /// A logical scaling factor to apply to the size provided by the SVG.
103    ScaleFactor(f32),
104}
105
106impl From<f32> for SvgSize {
107    fn from(scale_factor: f32) -> Self {
108        Self::ScaleFactor(scale_factor)
109    }
110}
111
112impl SvgRenderer {
113    /// Creates a new SVG renderer with the provided asset source.
114    pub fn new(asset_source: Arc<dyn AssetSource>) -> Self {
115        static SYSTEM_FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
116            let mut db = usvg::fontdb::Database::new();
117            db.load_system_fonts();
118            Arc::new(db)
119        });
120
121        // Build the enriched font DB lazily on first SVG render rather than
122        // eagerly at construction time. This avoids the expensive deep-clone
123        // of the system font database for code paths that never render SVGs
124        // (e.g. tests).
125        let enriched_fontdb: Arc<OnceLock<Arc<usvg::fontdb::Database>>> = Arc::new(OnceLock::new());
126
127        let default_font_resolver = usvg::FontResolver::default_font_selector();
128        let font_resolver = Box::new({
129            let asset_source = asset_source.clone();
130            move |font: &usvg::Font, db: &mut Arc<usvg::fontdb::Database>| {
131                if db.is_empty() {
132                    let fontdb = enriched_fontdb.get_or_init(|| {
133                        let mut db = (**SYSTEM_FONT_DB).clone();
134                        load_bundled_fonts(&*asset_source, &mut db);
135                        fix_generic_font_families(&mut db);
136                        Arc::new(db)
137                    });
138                    *db = fontdb.clone();
139                }
140                if let Some(id) = default_font_resolver(font, db) {
141                    return Some(id);
142                }
143                // fontdb doesn't recognize CSS system font keywords like "system-ui"
144                // or "ui-sans-serif", so fall back to sans-serif before any face.
145                let sans_query = usvg::fontdb::Query {
146                    families: &[usvg::fontdb::Family::SansSerif],
147                    ..Default::default()
148                };
149                db.query(&sans_query)
150                    .or_else(|| db.faces().next().map(|f| f.id))
151            }
152        });
153        let default_fallback_selection = usvg::FontResolver::default_fallback_selector();
154        let fallback_selection = Box::new(
155            move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc<usvg::fontdb::Database>| {
156                if is_emoji_presentation(ch) {
157                    if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES)
158                    {
159                        return Some(id);
160                    }
161                }
162
163                default_fallback_selection(ch, fonts, db)
164            },
165        );
166        let options = usvg::Options {
167            font_resolver: usvg::FontResolver {
168                select_font: font_resolver,
169                select_fallback: fallback_selection,
170            },
171            ..Default::default()
172        };
173        Self {
174            asset_source,
175            usvg_options: Arc::new(options),
176        }
177    }
178
179    /// Parses SVG data into a [`ParsedSvg`] that can be rasterized at any scale.
180    #[ztracing::instrument(skip_all)]
181    pub fn parse_svg(&self, bytes: &[u8]) -> Result<ParsedSvg, usvg::Error> {
182        usvg::Tree::from_data(bytes, &self.usvg_options).map(ParsedSvg)
183    }
184
185    /// Rasterizes a previously parsed SVG into an image buffer.
186    #[ztracing::instrument(skip_all)]
187    pub fn render_parsed(
188        &self,
189        svg: &ParsedSvg,
190        size: impl Into<SvgSize>,
191    ) -> Result<Arc<RenderImage>, usvg::Error> {
192        let (size, image_scale_factor) = match size.into() {
193            SvgSize::Size(size) => (SvgSize::Size(size), 1.0),
194            SvgSize::ExactSize(size) => (SvgSize::ExactSize(size), 1.0),
195            SvgSize::ScaleFactor(scale_factor) => (
196                SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR),
197                SMOOTH_SVG_SCALE_FACTOR,
198            ),
199        };
200        let pixmap = rasterize_tree(&svg.0, size)?;
201        let mut buffer =
202            image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()).unwrap();
203
204        for pixel in buffer.chunks_exact_mut(4) {
205            swap_rgba_pa_to_bgra(pixel);
206        }
207
208        let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)]));
209        image.scale_factor = image_scale_factor;
210        Ok(Arc::new(image))
211    }
212
213    /// Renders the given bytes into an image buffer.
214    pub fn render_single_frame(
215        &self,
216        bytes: &[u8],
217        scale_factor: f32,
218    ) -> Result<Arc<RenderImage>, usvg::Error> {
219        let svg = self.parse_svg(bytes)?;
220        self.render_parsed(&svg, scale_factor)
221    }
222
223    pub(crate) fn render_alpha_mask(
224        &self,
225        params: &RenderSvgParams,
226        bytes: Option<&[u8]>,
227    ) -> Result<Option<(Size<DevicePixels>, Vec<u8>)>> {
228        anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size");
229
230        let render_pixmap = |bytes| {
231            let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?;
232
233            // Convert the pixmap's pixels into an alpha mask.
234            let size = Size::new(
235                DevicePixels(pixmap.width() as i32),
236                DevicePixels(pixmap.height() as i32),
237            );
238            let alpha_mask = pixmap
239                .pixels()
240                .iter()
241                .map(|p| p.alpha())
242                .collect::<Vec<_>>();
243
244            Ok(Some((size, alpha_mask)))
245        };
246
247        if let Some(bytes) = bytes {
248            render_pixmap(bytes)
249        } else if let Some(bytes) = self.asset_source.load(&params.path)? {
250            render_pixmap(&bytes)
251        } else {
252            Ok(None)
253        }
254    }
255
256    fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result<Pixmap, usvg::Error> {
257        let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?;
258        rasterize_tree(&tree, size)
259    }
260}
261
262fn rasterize_tree(tree: &usvg::Tree, size: SvgSize) -> Result<Pixmap, usvg::Error> {
263    // Cap the size of the rendered pixmap to avoid texture allocation panics
264    // Related issue: #56466
265    const MAX_SIZE: f32 = 8192.0;
266
267    let svg_size = tree.size();
268    let (mut width, mut height) = match size {
269        SvgSize::Size(size) => {
270            let scale = i32::from(size.width) as f32 / svg_size.width();
271            (svg_size.width() * scale, svg_size.height() * scale)
272        }
273        SvgSize::ExactSize(size) => (i32::from(size.width) as f32, i32::from(size.height) as f32),
274        SvgSize::ScaleFactor(scale) => (svg_size.width() * scale, svg_size.height() * scale),
275    };
276
277    if width > MAX_SIZE {
278        log::warn!("Attempted to render pixmap where width ({width}) > MAX_SIZE ({MAX_SIZE})");
279    }
280    if height > MAX_SIZE {
281        log::warn!("Attempted to render pixmap where height ({height}) > MAX_SIZE ({MAX_SIZE})");
282    }
283    let scale = (MAX_SIZE / width).min(MAX_SIZE / height).min(1.0);
284    width *= scale;
285    height *= scale;
286
287    // Render the SVG to a pixmap with the specified width and height.
288    let mut pixmap = resvg::tiny_skia::Pixmap::new(width as u32, height as u32)
289        .ok_or(usvg::Error::InvalidSize)?;
290
291    let transform = resvg::tiny_skia::Transform::from_scale(
292        width / svg_size.width(),
293        height / svg_size.height(),
294    );
295
296    resvg::render(tree, transform, &mut pixmap.as_mut());
297
298    Ok(pixmap)
299}
300
301fn load_bundled_fonts(asset_source: &dyn AssetSource, db: &mut usvg::fontdb::Database) {
302    let font_paths = [
303        "fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf",
304        "fonts/lilex/Lilex-Regular.ttf",
305    ];
306    for path in font_paths {
307        match asset_source.load(path) {
308            Ok(Some(data)) => db.load_font_data(data.into_owned()),
309            Ok(None) => log::warn!("Bundled font not found: {path}"),
310            Err(error) => log::warn!("Failed to load bundled font {path}: {error}"),
311        }
312    }
313}
314
315// fontdb defaults generic families to Microsoft fonts ("Arial", "Times New Roman")
316// which aren't installed on most Linux systems. fontconfig normally overrides these,
317// but when it fails the defaults remain and all generic family queries return None.
318fn fix_generic_font_families(db: &mut usvg::fontdb::Database) {
319    use usvg::fontdb::{Family, Query};
320
321    let families_and_fallbacks: &[(Family<'_>, &str)] = &[
322        (Family::SansSerif, "IBM Plex Sans"),
323        // No serif font bundled; use sans-serif as best available fallback.
324        (Family::Serif, "IBM Plex Sans"),
325        (Family::Monospace, "Lilex"),
326        (Family::Cursive, "IBM Plex Sans"),
327        (Family::Fantasy, "IBM Plex Sans"),
328    ];
329
330    for (family, fallback_name) in families_and_fallbacks {
331        let query = Query {
332            families: &[*family],
333            ..Default::default()
334        };
335        if db.query(&query).is_none() {
336            match family {
337                Family::SansSerif => db.set_sans_serif_family(*fallback_name),
338                Family::Serif => db.set_serif_family(*fallback_name),
339                Family::Monospace => db.set_monospace_family(*fallback_name),
340                Family::Cursive => db.set_cursive_family(*fallback_name),
341                Family::Fantasy => db.set_fantasy_family(*fallback_name),
342                _ => {}
343            }
344        }
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use usvg::fontdb::{Database, Family, Query};
352
353    const IBM_PLEX_REGULAR: &[u8] =
354        include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
355    const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf");
356
357    #[test]
358    fn renders_parsed_svg_at_requested_size() -> Result<()> {
359        let renderer = SvgRenderer::new(Arc::new(()));
360        let svg = renderer.parse_svg(
361            br#"<svg xmlns="http://www.w3.org/2000/svg" width="24pt" height="12pt"></svg>"#,
362        )?;
363        let requested_size = Size::new(DevicePixels(24), DevicePixels(12));
364        let image = renderer.render_parsed(&svg, SvgSize::ExactSize(requested_size))?;
365
366        assert_eq!(image.size(0), requested_size);
367        Ok(())
368    }
369
370    #[test]
371    fn preserves_aspect_ratio_for_width_constrained_size() -> Result<()> {
372        let renderer = SvgRenderer::new(Arc::new(()));
373        let svg = renderer.parse_svg(
374            br#"<svg xmlns="http://www.w3.org/2000/svg" width="24pt" height="12pt"></svg>"#,
375        )?;
376        let image = renderer.render_parsed(
377            &svg,
378            SvgSize::Size(Size::new(DevicePixels(24), DevicePixels(24))),
379        )?;
380
381        assert_eq!(image.size(0), Size::new(DevicePixels(24), DevicePixels(12)));
382        Ok(())
383    }
384
385    fn db_with_bundled_fonts() -> Database {
386        let mut db = Database::new();
387        db.load_font_data(IBM_PLEX_REGULAR.to_vec());
388        db.load_font_data(LILEX_REGULAR.to_vec());
389        db
390    }
391
392    #[test]
393    fn text_with_split_glyph_clusters_in_mixed_fonts_does_not_panic() {
394        let mut db = Database::new();
395        db.load_font_data(IBM_PLEX_REGULAR.to_vec());
396        db.load_font_data(LILEX_REGULAR.to_vec());
397        let options = usvg::Options {
398            fontdb: std::sync::Arc::new(db),
399            ..Default::default()
400        };
401
402        // A base letter followed by a stack of combining marks. Under HarfBuzz's
403        // default cluster merging every mark glyph shares the base's byte index,
404        // which is the "glyph splitting" condition that triggered the panic. The
405        // chunk must use two different fonts so the buggy merge path runs.
406        let zalgo = "e\u{0301}\u{0302}\u{0303}\u{0304}\u{0306}\u{0307}\u{0308}\u{030a}";
407        let svg = format!(
408            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>"#
409        );
410
411        // Before the fix this aborts via panic with a message like
412        // "removal index (is 5) should be < len (is 5)".
413        usvg::Tree::from_data(svg.as_bytes(), &options)
414            .expect("SVG with mixed-font text should parse");
415    }
416
417    #[test]
418    fn test_is_emoji_presentation() {
419        let cases = [
420            ("a", false),
421            ("Z", false),
422            ("1", false),
423            ("#", false),
424            ("*", false),
425            ("漢", false),
426            ("中", false),
427            ("カ", false),
428            ("©", false),
429            ("♥", false),
430            ("😀", true),
431            ("✅", true),
432            ("🇺🇸", true),
433            // SVG fallback is not cluster-aware yet
434            ("©️", false),
435            ("♥️", false),
436            ("1️⃣", false),
437        ];
438        for (s, expected) in cases {
439            assert_eq!(
440                is_emoji_presentation(s.chars().next().unwrap()),
441                expected,
442                "for char {:?}",
443                s
444            );
445        }
446    }
447
448    #[test]
449    fn fix_generic_font_families_sets_all_families() {
450        let mut db = db_with_bundled_fonts();
451        fix_generic_font_families(&mut db);
452
453        let families = [
454            Family::SansSerif,
455            Family::Serif,
456            Family::Monospace,
457            Family::Cursive,
458            Family::Fantasy,
459        ];
460
461        for family in families {
462            let query = Query {
463                families: &[family],
464                ..Default::default()
465            };
466            assert!(
467                db.query(&query).is_some(),
468                "Expected generic family {family:?} to resolve after fix_generic_font_families"
469            );
470        }
471    }
472
473    #[test]
474    fn test_select_emoji_font_skips_family_without_glyph() {
475        let mut db = db_with_bundled_fonts();
476
477        let ibm_plex_sans = db
478            .query(&usvg::fontdb::Query {
479                families: &[usvg::fontdb::Family::Name("IBM Plex Sans")],
480                weight: usvg::fontdb::Weight(400),
481                stretch: usvg::fontdb::Stretch::Normal,
482                style: usvg::fontdb::Style::Normal,
483            })
484            .unwrap();
485        let lilex = db
486            .query(&usvg::fontdb::Query {
487                families: &[usvg::fontdb::Family::Name("Lilex")],
488                weight: usvg::fontdb::Weight(400),
489                stretch: usvg::fontdb::Stretch::Normal,
490                style: usvg::fontdb::Style::Normal,
491            })
492            .unwrap();
493        let selected = select_emoji_font('│', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap();
494
495        assert_eq!(selected, lilex);
496        assert!(!font_has_char(&db, ibm_plex_sans, '│'));
497        assert!(font_has_char(&db, selected, '│'));
498    }
499
500    #[test]
501    fn fix_generic_font_families_monospace_resolves_to_lilex() {
502        let mut db = db_with_bundled_fonts();
503        fix_generic_font_families(&mut db);
504
505        let query = Query {
506            families: &[Family::Monospace],
507            ..Default::default()
508        };
509        let id = db.query(&query).expect("Monospace should resolve");
510        let face = db.face(id).expect("Face should exist");
511        assert!(
512            face.families.iter().any(|(name, _)| name.contains("Lilex")),
513            "Monospace should map to Lilex, got {:?}",
514            face.families
515        );
516    }
517}