From 68cc0ffcf624ab2a97799dd6b4096165fff59183 Mon Sep 17 00:00:00 2001 From: Kayla Fortin Date: Tue, 13 Oct 2026 12:32:49 -0400 Subject: [PATCH] Give Windows Han text more than one fallback face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DirectWrite answers fontique's script fallback with a single face, mapped from one sample character with no locale. On an English system that pick can be a Japanese or Korean UI font, so a Simplified-only character such as 简 has no candidate and shapes as .notdef. `ParleyTextSystem::with_script_fallback_families` resolves the system's own pick for a script first, then appends the installed families it is given. The Windows text system uses it to add the Chinese, Japanese and Korean UI faces, plus SimSun-ExtB, after DirectWrite's Han pick. Co-Authored-By: Claude Opus 5.5 --- crates/gpui_ce_parley/src/text_system.rs | 28 +++++++++++++++ crates/gpui_windows/src/direct_write.rs | 45 +++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/gpui_ce_parley/src/text_system.rs b/crates/gpui_ce_parley/src/text_system.rs index 7492504972..8af4561f82 100644 --- a/crates/gpui_ce_parley/src/text_system.rs +++ b/crates/gpui_ce_parley/src/text_system.rs @@ -1463,6 +1463,34 @@ impl ParleyTextSystem { self } + /// Appends installed `families` after the operating system's own fallback for `script`. + /// + /// Some platforms answer a script fallback with a single face, chosen from one sample + /// character, so a character that face lacks has no candidate at all. Families that are + /// not installed are skipped, and the system's pick keeps its place first. + pub fn with_script_fallback_families( + self, + script: [u8; 4], + families: impl IntoIterator>, + ) -> Self { + { + let mut state = self.parley.lock(); + let collection = &mut state.font_context.collection; + let key = fontique::FallbackKey::new(fontique::Script::from_bytes(script), None); + // Resolve the system's pick first: a key that already has families never asks it. + let mut ids = collection.fallback_families(key).collect::>(); + for family in families { + if let Some(id) = collection.family_id(family.as_ref()) + && !ids.contains(&id) + { + ids.push(id); + } + } + collection.set_fallbacks(key, ids.into_iter()); + } + self + } + fn font_families<'a>( &'a self, descriptor: &'a Font, diff --git a/crates/gpui_windows/src/direct_write.rs b/crates/gpui_windows/src/direct_write.rs index efb40879c6..33f2e4f40c 100644 --- a/crates/gpui_windows/src/direct_write.rs +++ b/crates/gpui_windows/src/direct_write.rs @@ -91,7 +91,19 @@ impl DirectWriteTextSystem { "Segoe UI", BitmapFallbackGlyphRasterizer::new(SharedDirectWriteRenderer(renderer.clone())), ) - .with_fallback_families(["Lilex", "IBM Plex Sans", "Arial"]); + .with_fallback_families(["Lilex", "IBM Plex Sans", "Arial"]) + // DirectWrite picks one Han face from a sample character; on a Japanese or Korean + // pick, Simplified-only characters such as 简 would otherwise draw as .notdef. + .with_script_fallback_families( + *b"Hani", + [ + "Microsoft YaHei UI", + "Microsoft JhengHei UI", + "Yu Gothic UI", + "Malgun Gothic", + "SimSun-ExtB", + ], + ); Ok(Self { parley, renderer }) } @@ -959,6 +971,37 @@ mod tests { use gpui_parley::FontSynthesis; use std::collections::BTreeMap; + #[test] + fn han_fallback_covers_simplified_only_characters() -> Result<()> { + let system = DirectWriteTextSystem::new_headless()?; + // 简 is Simplified-only and absent from Japanese and Korean UI fonts; the rest + // are shared Han, Japanese and Hangul text that must keep a real glyph too. + for text in ["简体中文", "繁體中文", "日本語", "한국어"] { + let runs = [gpui::TextRun { + len: text.len(), + font: font("Segoe UI"), + ..Default::default() + }]; + let layout = system.layout_text(TextLayoutRequest { + text, + font_size: px(16.0), + runs: &runs, + options: Default::default(), + }); + let glyphs = layout + .paint_fragments + .iter() + .flat_map(|fragment| fragment.glyphs.iter()) + .collect::>(); + assert_eq!(glyphs.len(), text.chars().count(), "{text}"); + assert!( + glyphs.iter().all(|glyph| glyph.id != GlyphId(0)), + "{text} shaped a .notdef glyph" + ); + } + Ok(()) + } + #[test] fn fixed_fonts_cover_native_modes_instances_and_empty_glyphs() -> Result<()> { let system = DirectWriteTextSystem::new_headless()?;