From dc1b048f8ac7c1c80121b793aef5a74b884fd8ac Mon Sep 17 00:00:00 2001 From: FeiLiuEM Date: Mon, 12 Oct 2026 20:49:02 +0800 Subject: [PATCH 1/2] text: the Chinese fallback families follow the script the locale asks for The Han fallback list carried a Simplified and a Traditional face for every platform (Microsoft YaHei next to Microsoft JhengHei, PingFang SC next to PingFang TC), Simplified first. The fallback search keeps the first family whose face covers the character, and the two scripts share their code points - only the glyph forms differ - so a zh-hant interface was drawn with Simplified forms, and a Traditional face could not win on a machine that also had a Simplified one. The list is now two, picked by the script the locale in force asks for: Hant, or a Taiwan, Hong Kong or Macao region. A bare zh stays Simplified, as the interface's own locale mapping resolves it. `asks_traditional_chinese` normalises case and separators, and `chinese_families_for` is pure, so the choice is testable without installed fonts. The Han order and the Japanese and Korean lists are untouched. Verified: cargo fmt --all --check clean, cargo clippy -p effectcraft-text --all-targets -- -D warnings clean, cargo test -p effectcraft-text 54 tests pass. --- crates/text/src/fonts.rs | 65 ++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 4091344d..7ef9c1d8 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -588,21 +588,16 @@ const JAPANESE_FAMILIES: &[&str] = &[ "IPAGothic", "VL Gothic", ]; -const CHINESE_FAMILIES: &[&str] = &[ - "Microsoft YaHei", - "Microsoft JhengHei", - "SimSun", - "MingLiU", - "PingFang SC", - "PingFang TC", - "Noto Sans CJK SC", - "Noto Sans CJK TC", - "Noto Sans SC", - "Noto Sans TC", - "Source Han Sans SC", - "Source Han Sans TC", - "WenQuanYi Micro Hei", -]; +/// Han ideograph families for Simplified Chinese, in the order the platforms' own fallback tables +/// prefer them (Windows, macOS, then the Linux/Noto names). Missing ones cost one name lookup each. +const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = + &["Microsoft YaHei", "SimSun", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei"]; +/// The same for Traditional Chinese. The two scripts share almost all of their code points but not +/// the glyph forms (`东` against `東`'s neighbours, `直`, `骨`), so one list for both drew a +/// Traditional interface with Simplified forms - the faces do not differ in *coverage*, which is +/// what the fallback search checks, so the script has to pick the list. +const TRADITIONAL_CHINESE_FAMILIES: &[&str] = + &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC", "WenQuanYi Micro Hei"]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; /// The platforms' plain text families for the other scripts the bundled fonts lack, in the same /// order (Windows, macOS, then the Linux/Noto names). @@ -691,10 +686,29 @@ fn han_order() -> [FallbackScript; 3] { han_order_for(&active_cjk_locale()) } +/// Whether a CJK tag asks for Traditional Chinese: a `Hant` script, or a Taiwan, Hong Kong or +/// Macao region (`zh-TW`, `zh_HK`, `zh_MO.UTF-8`). Anything else Chinese - including a bare `zh` - +/// is Simplified, as in the interface's own locale mapping. +fn asks_traditional_chinese(tag: &str) -> bool { + tag.to_ascii_lowercase().split(['-', '_', '.']).any(|p| matches!(p, "hant" | "tw" | "hk" | "mo")) +} + +/// The Chinese families to try first for `tag`: one list per script, since coverage does not tell +/// the two apart - a Simplified face covers the Traditional glyphs' code points, so the fallback +/// search would keep it and draw the wrong forms. +fn chinese_families_for(tag: &str) -> &'static [&'static str] { + if asks_traditional_chinese(tag) { TRADITIONAL_CHINESE_FAMILIES } else { SIMPLIFIED_CHINESE_FAMILIES } +} + +/// [`chinese_families_for`] for the locale in force ([`active_cjk_locale`]). +fn chinese_families() -> &'static [&'static str] { + chinese_families_for(&active_cjk_locale()) +} + fn families_for(s: FallbackScript) -> &'static [&'static str] { match s { FallbackScript::Japanese => JAPANESE_FAMILIES, - FallbackScript::Han => CHINESE_FAMILIES, + FallbackScript::Han => chinese_families(), FallbackScript::Korean => KOREAN_FAMILIES, FallbackScript::Arabic => ARABIC_FAMILIES, FallbackScript::Hebrew => HEBREW_FAMILIES, @@ -1079,6 +1093,25 @@ mod tests { assert_eq!(han_order(), han_order_for(&active_cjk_locale())); } + #[test] + fn the_chinese_family_list_follows_the_script_the_locale_asks_for() { + for tag in ["zh", "zh-CN", "zh_Hans", "zh_CN.UTF-8", "zh-SG"] { + assert_eq!(chinese_families_for(tag), SIMPLIFIED_CHINESE_FAMILIES, "{tag}"); + } + for tag in ["zh-Hant", "zh-Hant-TW", "zh_TW", "zh-HK", "zh_MO.UTF-8"] { + assert_eq!(chinese_families_for(tag), TRADITIONAL_CHINESE_FAMILIES, "{tag}"); + } + // Each list carries its own script's faces and not the other's, which is the point of + // splitting them: `PingFang SC` covers the same code points as `PingFang TC`, so the + // fallback search would have kept it and drawn Simplified forms in a Traditional interface. + assert!(SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang SC") && !f.contains("Source"))); + assert!(!SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("TC") || f.contains("JhengHei"))); + assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang TC"))); + assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei"))); + // Han text is the only thing these lists serve; the Japanese order does not change. + assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese); + } + #[test] fn bundled_ui_fonts_cover_the_ukrainian_alphabet() { const ALPHABET: &str = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя’«»"; From 35124cc9242afaa2311de43a71af8dd6a325edb1 Mon Sep 17 00:00:00 2001 From: FeiLiuEM Date: Mon, 12 Oct 2026 21:57:49 +0800 Subject: [PATCH 2/2] text: the Traditional list drops a Simplified face and adds the macOS Traditional ones WenQuanYi Micro Hei is a Simplified-oriented face, so keeping it in the Traditional list - even as a last resort - could still draw Simplified forms, which is the one thing the split is for. Heiti TC and Hiragino Sans CNS are the macOS Traditional faces the family's own lists use (photocraft's `CjkScript::TraditionalChinese`). Dropping the entry does not lose the face: the generic scan behind the script lists still finds it when nothing Traditional is installed. The test now walks the `FallbackScript::Han` arm as well, so a list wired to the wrong script fails it, and it refuses a WenQuanYi entry in the Traditional list. --- crates/text/src/fonts.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 7ef9c1d8..401b107a 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -597,7 +597,7 @@ const SIMPLIFIED_CHINESE_FAMILIES: &[&str] = /// Traditional interface with Simplified forms - the faces do not differ in *coverage*, which is /// what the fallback search checks, so the script has to pick the list. const TRADITIONAL_CHINESE_FAMILIES: &[&str] = - &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC", "WenQuanYi Micro Hei"]; + &["Microsoft JhengHei", "MingLiU", "PingFang TC", "Heiti TC", "Hiragino Sans CNS", "Noto Sans CJK TC", "Noto Sans TC", "Source Han Sans TC"]; const KOREAN_FAMILIES: &[&str] = &["Malgun Gothic", "Gulim", "Apple SD Gothic Neo", "Noto Sans CJK KR", "Noto Sans KR", "Source Han Sans KR", "NanumGothic"]; /// The platforms' plain text families for the other scripts the bundled fonts lack, in the same /// order (Windows, macOS, then the Linux/Noto names). @@ -1107,7 +1107,10 @@ mod tests { assert!(SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang SC") && !f.contains("Source"))); assert!(!SIMPLIFIED_CHINESE_FAMILIES.iter().any(|f| f.contains("TC") || f.contains("JhengHei"))); assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("PingFang TC"))); - assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei"))); + assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei") || f.contains("WenQuanYi"))); + assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("Heiti TC") || f.contains("Hiragino Sans CNS"))); + // The arm the fallback itself walks: Han hands over the list the locale's script picked. + assert_eq!(families_for(FallbackScript::Han), chinese_families_for(&active_cjk_locale())); // Han text is the only thing these lists serve; the Japanese order does not change. assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese); }