From d85601f8eb9bfb6b86d4c48c2926e1c43a7619be Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 23:38:01 +0200 Subject: [PATCH] UI: a Simplified Chinese interface picks a face with the simplified hanzi (fixes #1141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Chinese UI fallback was chosen by probing 文, which Japanese faces cover too. Fallback takes a face already loaded first, so after the English interface had loaded a Japanese face for kana, switching to Simplified Chinese reused it, and the simplified-only hanzi (选, 删, 层, 设, 页 ...) drew as boxes. A Simplified Chinese interface now probes 页, as wordcraft does for its interface font (storytold/wordcraft#248). --- crates/ui-egui/src/theme.rs | 41 +++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index abc6f576..2ed02c3a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -397,7 +397,6 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // Kana is unambiguously Japanese; Han ideographs are shared, so the order decides which // language's face draws them. const KANA: (char, &str) = ('あ', "japanese-system"); - const HAN: (char, &str) = ('文', "chinese-system"); // Hangul is probed last: with no Korean font installed the lookup can land on a catch-all // face (Unifont) that also covers Arabic or Indic text but can't shape it, so the // script-specific faces must be registered ahead of it. A face already seen is skipped. @@ -405,7 +404,8 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str) // Han text follows the interface language, not the operating system's locale. fonts::set_cjk_locale(cjk_locale(language)); let base = fonts::resolve("Inter", "Regular").face; - let cjk = if is_chinese(language) { [HAN, KANA] } else { [KANA, HAN] }; + let han = (han_probe(language), "chinese-system"); + let cjk = if is_chinese(language) { [han, KANA] } else { [KANA, han] }; let mut names = Vec::new(); let mut seen = Vec::new(); for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) { @@ -442,6 +442,15 @@ fn cjk_locale(language: &str) -> &str { } } +/// The Han character that picks the Chinese fallback face. A Simplified Chinese interface asks for +/// a simplified-only hanzi: a Japanese face covers the shared ideographs (文) but not 页, 选 or 删, +/// and the fallback takes a face already loaded first, so the Japanese face loaded for kana would +/// draw the Chinese interface with boxes (#1141). +#[cfg(not(target_arch = "wasm32"))] +fn han_probe(language: &str) -> char { + if language == "zh-hans" { '页' } else { '文' } +} + /// Whether `language` is one of the Chinese catalogs. #[cfg(not(target_arch = "wasm32"))] fn is_chinese(language: &str) -> bool { @@ -583,6 +592,34 @@ mod script_font_tests { } } + /// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English + /// interface had already loaded a Japanese face for kana, and that face covers the Han probe, + /// so it became the Chinese fallback too, without the simplified-only hanzi. + #[test] + fn simplified_chinese_interface_draws_simplified_hanzi_after_another_language() { + let ctx = egui::Context::default(); + let tokens = super::Tokens::for_kind(super::ThemeKind::Dark); + super::install(&ctx, &tokens, "en"); + super::install(&ctx, &tokens, "zh-hans"); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + let missing: String = ctx.fonts_mut(|fonts| { + let replacement = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect; + // Simplified-only hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面). + "选删层设导页" + .chars() + .filter(|ch| { + let uv = fonts.layout_no_wrap(ch.to_string(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect; + (uv.min, uv.max) == (replacement.min, replacement.max) + }) + .collect() + }); + // Asked after the layout: the coverage lookup itself loads a face with these hanzi. + let drawable: String = missing.chars().filter(|&ch| installed(ch)).collect(); + assert!(drawable.is_empty(), "an installed face covers {drawable}, but the Simplified Chinese interface draws boxes"); + } + /// egui shapes each run of one face: Arabic letters take their joined forms, and an Arabic /// word reads right to left (its first letter drawn rightmost). #[test]