@@ -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]]
@@ -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"] }
@@ -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, 'β'));
+ }
+}