gpui: Fix emoji rendering in SVG preview (#51569)

Alan P John and Smit Barmase created

Closes #50483 

## Findings

As reported in the original issue, emojis in SVG preview were not
rendering consistently with the editor.

The SVG renderer uses `usvg`/`resvg` for parsing and rendering SVG
files. The first problem was that emoji fonts were not rendering at all,
which was fixed by enabling the `raster_images` on `resvg`.

Beyond that it was observed that the default font fallback mechanism in
`usvg` searches through the font database alphabetically without
prioritizing emoji fonts. This caused emojis to sometimes render in
non-emoji fonts that happened to contain glyph mappings for those
characters.

For example, on Linux systems with the default
`uvsg::FontResolver::default_fallback_selector()`:
- The character βœ… would fall back to `FreeSerif` (monochrome)
- Instead of `Noto Color Emoji` (full color)

Log output showed the inconsistent behavior:
```
WARN  [usvg::text] Fallback from FreeSans to Noto Color Emoji.
WARN  [usvg::text] Fallback from FreeSans to FreeSerif.
WARN  [usvg::text] Fallback from FreeSans to Noto Color Emoji.
```

<img width="480" height="480" alt="Image"
src="https://github.com/user-attachments/assets/e065608f-a98b-4e67-9429-4aed16810c2c"
/>


This created a jarring inconsistency where the same emoji character
would render differently in:
- The editor (correct, using platform emoji fonts)
- SVG preview (incorrect, using arbitrary fallback fonts)
## Solution

If the specified font in SVG is available on the system, we should show
that. If not, we should fallback to what editors show today for that
emoji.

This PR implements emoji-aware font fallback that:

1. **Enabled `raster_images` build feature** to render emojis in SVG.
2. **Detects emoji characters** using Unicode emoji properties (via
`\p{Emoji}` regex pattern), consistent with how we check for emoji in
the Editor as well.
3. **Preserves user-specified fonts** by only intervening when the
default font resolver would use a non-emoji font for emoji characters

### Font Family Selection

I avoided completely reusing/rebuilding the logic for emoji font
selection used by the editor as `uvsg` internally does quite a bit of
the job and it felt like overcomplicating the solution. Instead using
hard coded platform specific font family names.
The hardcoded emoji font families are sourced from Zed's existing
platform-specific text rendering systems:

- **macOS**: `Apple Color Emoji`, `.AppleColorEmojiUI`  
Source:
https://github.com/zed-industries/zed/blob/db622edc8b26bd138c91027a02792a84c083acbf/crates/gpui_macos/src/text_system.rs#L353-L359
- **Linux/FreeBSD**: `Noto Color Emoji`, `Emoji One`  
Source:
https://github.com/zed-industries/zed/blob/db622edc8b26bd138c91027a02792a84c083acbf/crates/gpui_wgpu/src/cosmic_text_system.rs#L642-L646
- **Windows**: `Segoe UI Emoji`, `Segoe UI Symbol`  
  Source: Standard Windows emoji font stack

These match the fonts the editor uses for emoji rendering on each
platform.
To break down further into the similarity and differences in the emoji
font resolution:
**Similarities:**
- Both now use the regex based emoji detection logic
- Both prioritize the same platform-specific emoji font families
- Both support color emoji rendering
**Differences:**
- **Editor**: Uses platform-native text shaping (CoreText on macOS,
DirectWrite on Windows, cosmic-text on Linux) which handles fallback
automatically
- **SVG**: Uses custom fallback selector that explicitly queries emoji
fonts first, then falls back to default usvg behavior
## Testing

- Added unit tests for `is_emoji_character` in `util` crate
- Tested emoji detection for various Unicode characters
- [ ] Verified platform-specific font lists compile correctly (Only
linux done)
- [ ] Manual testing with SVG files containing emojis on all platforms
(Only linux done)

Release Notes:

- Fixed SVG preview to render emojis consistently with the editor by
prioritizing platform-specific color emoji fonts

---------

Signed-off-by: Alan P John <alanpjohn@outlook.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>

Change summary

Cargo.lock                      |   5 +
crates/gpui/Cargo.toml          |   9 +
crates/gpui/src/svg_renderer.rs | 152 ++++++++++++++++++++++++++++++++++
3 files changed, 162 insertions(+), 4 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -7652,6 +7652,7 @@ dependencies = [
  "rand 0.9.2",
  "raw-window-handle",
  "refineable",
+ "regex",
  "reqwest_client",
  "resvg",
  "scheduler",
@@ -7667,6 +7668,7 @@ dependencies = [
  "sum_tree",
  "taffy",
  "thiserror 2.0.17",
+ "ttf-parser 0.25.1",
  "unicode-segmentation",
  "url",
  "usvg",
@@ -14609,12 +14611,15 @@ version = "0.45.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
 dependencies = [
+ "gif",
+ "image-webp",
  "log",
  "pico-args",
  "rgb",
  "svgtypes",
  "tiny-skia",
  "usvg",
+ "zune-jpeg",
 ]
 
 [[package]]

crates/gpui/Cargo.toml πŸ”—

@@ -70,14 +70,17 @@ chrono.workspace = true
 profiling.workspace = true
 rand.workspace = true
 raw-window-handle = "0.6"
+regex.workspace = true
 refineable.workspace = true
 scheduler.workspace = true
 resvg = { version = "0.45.0", default-features = false, features = [
     "text",
     "system-fonts",
     "memmap-fonts",
+    "raster-images"
 ] }
 usvg = { version = "0.45.0", default-features = false }
+ttf-parser = "0.25"
 util_macros.workspace = true
 schemars.workspace = true
 seahash = "4.1"
@@ -145,12 +148,12 @@ backtrace.workspace = true
 collections = { workspace = true, features = ["test-support"] }
 env_logger.workspace = true
 gpui_platform = { workspace = true, features = ["font-kit"] }
+gpui_util = { workspace = true }
 lyon = { version = "1.0", features = ["extra"] }
+proptest = { workspace = true }
 rand.workspace = true
 scheduler = { workspace = true, features = ["test-support"] }
-unicode-segmentation.workspace = true
-gpui_util = { workspace = true }
-proptest = { workspace = true }
+unicode-segmentation = { workspace = true }
 
 [target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
 http_client = { workspace = true, features = ["test-support"] }

crates/gpui/src/svg_renderer.rs πŸ”—

@@ -10,6 +10,73 @@ use std::{
     sync::{Arc, LazyLock},
 };
 
+#[cfg(target_os = "macos")]
+const EMOJI_FONT_FAMILIES: &[&str] = &["Apple Color Emoji", ".AppleColorEmojiUI"];
+
+#[cfg(target_os = "windows")]
+const EMOJI_FONT_FAMILIES: &[&str] = &["Segoe UI Emoji", "Segoe UI Symbol"];
+
+#[cfg(any(target_os = "linux", target_os = "freebsd"))]
+const EMOJI_FONT_FAMILIES: &[&str] = &[
+    "Noto Color Emoji",
+    "Emoji One",
+    "Twitter Color Emoji",
+    "JoyPixels",
+];
+
+#[cfg(not(any(
+    target_os = "macos",
+    target_os = "windows",
+    target_os = "linux",
+    target_os = "freebsd",
+)))]
+const EMOJI_FONT_FAMILIES: &[&str] = &[];
+
+fn is_emoji_presentation(c: char) -> bool {
+    static EMOJI_PRESENTATION_REGEX: LazyLock<regex::Regex> =
+        LazyLock::new(|| regex::Regex::new("\\p{Emoji_Presentation}").unwrap());
+    let mut buf = [0u8; 4];
+    EMOJI_PRESENTATION_REGEX.is_match(c.encode_utf8(&mut buf))
+}
+
+fn font_has_char(db: &usvg::fontdb::Database, id: usvg::fontdb::ID, ch: char) -> bool {
+    db.with_face_data(id, |font_data, face_index| {
+        ttf_parser::Face::parse(font_data, face_index)
+            .ok()
+            .and_then(|face| face.glyph_index(ch))
+            .is_some()
+    })
+    .unwrap_or(false)
+}
+
+fn select_emoji_font(
+    ch: char,
+    fonts: &[usvg::fontdb::ID],
+    db: &usvg::fontdb::Database,
+    families: &[&str],
+) -> Option<usvg::fontdb::ID> {
+    for family_name in families {
+        let query = usvg::fontdb::Query {
+            families: &[usvg::fontdb::Family::Name(family_name)],
+            weight: usvg::fontdb::Weight(400),
+            stretch: usvg::fontdb::Stretch::Normal,
+            style: usvg::fontdb::Style::Normal,
+        };
+
+        let Some(id) = db.query(&query) else {
+            continue;
+        };
+
+        if fonts.contains(&id) || !font_has_char(db, id, ch) {
+            continue;
+        }
+
+        return Some(id);
+    }
+
+    None
+}
+
 /// When rendering SVGs, we render them at twice the size to get a higher-quality result.
 pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.;
 
@@ -52,10 +119,23 @@ impl SvgRenderer {
                 default_font_resolver(font, db)
             },
         );
+        let default_fallback_selection = usvg::FontResolver::default_fallback_selector();
+        let fallback_selection = Box::new(
+            move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc<usvg::fontdb::Database>| {
+                if is_emoji_presentation(ch) {
+                    if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES)
+                    {
+                        return Some(id);
+                    }
+                }
+
+                default_fallback_selection(ch, fonts, db)
+            },
+        );
         let options = usvg::Options {
             font_resolver: usvg::FontResolver {
                 select_font: font_resolver,
-                select_fallback: usvg::FontResolver::default_fallback_selector(),
+                select_fallback: fallback_selection,
             },
             ..Default::default()
         };
@@ -148,3 +228,73 @@ impl SvgRenderer {
         Ok(pixmap)
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const IBM_PLEX_REGULAR: &[u8] =
+        include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf");
+    const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf");
+
+    #[test]
+    fn test_is_emoji_presentation() {
+        let cases = [
+            ("a", false),
+            ("Z", false),
+            ("1", false),
+            ("#", false),
+            ("*", false),
+            ("ζΌ’", false),
+            ("δΈ­", false),
+            ("γ‚«", false),
+            ("Β©", false),
+            ("β™₯", false),
+            ("πŸ˜€", true),
+            ("βœ…", true),
+            ("πŸ‡ΊπŸ‡Έ", true),
+            // SVG fallback is not cluster-aware yet
+            ("©️", false),
+            ("β™₯️", false),
+            ("1️⃣", false),
+        ];
+        for (s, expected) in cases {
+            assert_eq!(
+                is_emoji_presentation(s.chars().next().unwrap()),
+                expected,
+                "for char {:?}",
+                s
+            );
+        }
+    }
+
+    #[test]
+    fn test_select_emoji_font_skips_family_without_glyph() {
+        let mut db = usvg::fontdb::Database::new();
+
+        db.load_font_data(IBM_PLEX_REGULAR.to_vec());
+        db.load_font_data(LILEX_REGULAR.to_vec());
+
+        let ibm_plex_sans = db
+            .query(&usvg::fontdb::Query {
+                families: &[usvg::fontdb::Family::Name("IBM Plex Sans")],
+                weight: usvg::fontdb::Weight(400),
+                stretch: usvg::fontdb::Stretch::Normal,
+                style: usvg::fontdb::Style::Normal,
+            })
+            .unwrap();
+        let lilex = db
+            .query(&usvg::fontdb::Query {
+                families: &[usvg::fontdb::Family::Name("Lilex")],
+                weight: usvg::fontdb::Weight(400),
+                stretch: usvg::fontdb::Stretch::Normal,
+                style: usvg::fontdb::Style::Normal,
+            })
+            .unwrap();
+        let selected = select_emoji_font('β”‚', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap();
+
+        assert_eq!(selected, lilex);
+        assert!(!font_has_char(&db, ibm_plex_sans, 'β”‚'));
+        assert!(font_has_char(&db, selected, 'β”‚'));
+    }
+}