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, '│')); + } +}