From 5197cb4da9a7861eecbaeef9c0f019f46ff6d913 Mon Sep 17 00:00:00 2001 From: Alan P John Date: Fri, 27 Mar 2026 12:49:49 +0530 Subject: [PATCH] gpui: Fix emoji rendering in SVG preview (#51569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. ``` Image 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 Co-authored-by: Smit Barmase --- Cargo.lock | 5 ++ crates/gpui/Cargo.toml | 9 +- crates/gpui/src/svg_renderer.rs | 152 +++++++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 33645135abda30a991f7645338fa84bd1618d574..9da2e0144da52421bb2e2d044899d2c881ce3ad4 100644 --- a/Cargo.lock +++ b/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]] diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 9eb2de936c2e1db1d80cc3627db5594152e7223e..cb4a48f63103118aafe78398d4842634c976ef9d 100644 --- a/crates/gpui/Cargo.toml +++ b/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"] } diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index f82530f8d10fab074dd5e116114cf028a8a19cfe..217555e3b0e295d06e375e19d013e0b520118e0b 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/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 = + 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 { + 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| { + 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, '│')); + } +}