diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index db1180e..b437b8a 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -434,8 +434,9 @@ pub fn system_font_dirs() -> Vec { if let Some(h) = &home { dirs.push(h.join("Library/Fonts")); } - // Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font. - for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] { + // Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font + // (`Assets` before macOS 11, `AssetsV2` since). + for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs", "/System/Library/Assets"] { let Ok(entries) = std::fs::read_dir(parent) else { continue }; for e in entries.flatten() { if e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 2ac6eca..042480b 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -18,7 +18,7 @@ use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; pub use kurbo::BezPath; use skrifa::MetadataProvider; use skrifa::instance::Size; -pub use ui_fallback::{SystemUiFont, system_cjk_ui_font, ui_needs_system_cjk}; +pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, ui_needs_system_cjk, ui_uncovered_cjk}; /// A font from the optional craft-fonts build input (https://github.com/storytold/craft-fonts; /// empty unless built with `CRAFT_FONTS_DIR`, see `build.rs`). diff --git a/crates/fonts/src/ui_fallback.rs b/crates/fonts/src/ui_fallback.rs index 5427f02..6cbe8f7 100644 --- a/crates/fonts/src/ui_fallback.rs +++ b/crates/fonts/src/ui_fallback.rs @@ -1,7 +1,8 @@ -//! An installed CJK font for the interface when no embedded craft-fonts face covers the interface -//! language (#241): a build without craft-fonts, or one whose craft-fonts checkout lacks the -//! Chinese face, would otherwise draw Chinese menus as boxes. Native only; wasm has no system -//! fonts to read. +//! An installed CJK font for the interface, after the embedded craft-fonts faces, when they leave +//! characters of the interface text without a glyph: a build without craft-fonts, one whose +//! craft-fonts checkout lacks the Chinese face (#241), or one whose faces lack characters the +//! catalogs use (#486) would otherwise draw Chinese menus with boxes and `?`. Native only; wasm +//! has no system fonts to read. //! //! It looks for a short list of well-known files in the platform's font folders //! ([`system_font_dirs`](crate::system_font_dirs)) rather than waiting for the full system font @@ -22,31 +23,72 @@ pub struct SystemUiFont { pub index: u32, } -/// Do the embedded interface faces (`ui_cjk_fonts`) leave the interface without CJK glyphs it -/// needs? A Chinese interface (`prefer_hans`) needs a Chinese (`Hans`) face: the Japanese one -/// lacks the simplified-only hanzi (页, 选, 删 …). Any other interface still needs some CJK face, -/// for the language names in the Options menu and CJK file names. -pub fn ui_needs_system_cjk(prefer_hans: bool, embedded: &[&CraftFont]) -> bool { - if prefer_hans { !embedded.iter().any(|f| f.scripts.contains(&"Hans")) } else { embedded.is_empty() } +/// Is `c` drawn from a CJK font: Han ideographs, kana, bopomofo, Hangul, CJK punctuation or a +/// full-width form? (Latin, Cyrillic and symbols come from the interface's Latin faces.) +pub fn is_cjk(c: char) -> bool { + matches!(c, + '\u{2E80}'..='\u{2FDF}' // radicals + | '\u{3000}'..='\u{33FF}' // punctuation, kana, bopomofo, compatibility + | '\u{3400}'..='\u{4DBF}' // extension A + | '\u{4E00}'..='\u{9FFF}' // unified ideographs + | '\u{AC00}'..='\u{D7AF}' // Hangul syllables + | '\u{F900}'..='\u{FAFF}' // compatibility ideographs + | '\u{FE30}'..='\u{FE4F}' // vertical forms + | '\u{FF00}'..='\u{FFEF}' // full-width forms + | '\u{20000}'..='\u{3FFFF}' // extensions B and later + ) +} + +/// The distinct CJK characters of `texts` (see [`is_cjk`]) that none of the `embedded` faces +/// maps, in code point order. A craft-fonts face may lack characters the interface catalogs use +/// (an older or subset file), and those would draw as boxes or `?` (#486). +pub fn ui_uncovered_cjk(texts: &[&str], embedded: &[&CraftFont]) -> Vec { + let faces: Vec<_> = embedded.iter().filter_map(|f| skrifa::FontRef::from_index(f.bytes, 0).ok()).map(|f| f.charmap()).collect(); + let chars: std::collections::BTreeSet = texts.iter().flat_map(|t| t.chars()).filter(|c| is_cjk(*c)).collect(); + chars.into_iter().filter(|c| !faces.iter().any(|m| m.map(*c).is_some())).collect() +} + +/// Do the embedded interface faces (`ui_cjk_fonts`) leave CJK characters of `texts` (the +/// interface catalogs and language names that can be on screen) without a glyph? Then an +/// installed CJK font goes after them. No embedded face (a build without craft-fonts) leaves +/// every CJK character uncovered; a Japanese face alone lacks the simplified-only hanzi (页, 选, +/// 删 …, #241) and some traditional ones (啟, 內 …, #486). +pub fn ui_needs_system_cjk(texts: &[&str], embedded: &[&CraftFont]) -> bool { + !ui_uncovered_cjk(texts, embedded).is_empty() } /// Files larger than this are skipped (the big system CJK collections are around 20–80 MB). const MAX_FILE: u64 = 128 << 20; /// Faces looked at in one collection. const MAX_FACES: u32 = 64; +/// Entries looked at in one macOS font asset folder (each font is one `.asset` folder). +const MAX_ASSETS: usize = 8192; +/// Characters a face's coverage is measured on. +const MAX_NEEDED: usize = 4096; /// A character only a font for the language has: a simplified-only hanzi, or kana. fn probe(hans: bool) -> char { if hans { '页' } else { 'の' } } -/// Font files to look for, relative to a font folder, best first. Chinese faces cover kana too, -/// so the Japanese list ends with the Chinese one. +/// Font files to look for, relative to a font folder (or absolute), best first. Chinese faces +/// cover kana too, so the Japanese list ends with the Chinese one. fn candidates(hans: bool) -> Vec<&'static str> { let (zh, ja): (&[&str], &[&str]) = if cfg!(windows) { (&["Deng.ttf", "msyh.ttc", "simhei.ttf", "simsun.ttc", "NotoSansSC-VF.ttf"], &["YuGothR.ttc", "meiryo.ttc", "msgothic.ttc"]) } else if cfg!(target_os = "macos") { - (&["PingFang.ttc", "Hiragino Sans GB.ttc", "STHeiti Light.ttc", "STHeiti Medium.ttc"], &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans W3.ttc"]) + ( + &[ + // An asset on macOS 10.15 and later (see `with_asset_data`). + "PingFang.ttc", + "Hiragino Sans GB.ttc", + "STHeiti Light.ttc", + "STHeiti Medium.ttc", + // The system interface's own copy, present when the PingFang asset isn't. + "/System/Library/PrivateFrameworks/FontServices.framework/Resources/Reserved/PingFangUI.ttc", + ], + &["ヒラギノ角ゴシック W3.ttc", "Hiragino Sans W3.ttc"], + ) } else { ( &[ @@ -69,31 +111,67 @@ fn candidates(hans: bool) -> Vec<&'static str> { if hans { zh.to_vec() } else { ja.iter().chain(zh).copied().collect() } } -/// The installed CJK font for a Chinese (`hans`) or other interface, if one is found. Reads at -/// most one font file per candidate; never panics on damaged files. -pub fn system_cjk_ui_font(hans: bool) -> Option { - find_ui_font(&crate::system_font_dirs(), &candidates(hans), probe(hans), hans) +/// The installed CJK font for a Chinese (`hans`) or other interface that best covers `needed` +/// (the characters the embedded faces lack; empty: a character typical of the language), if one +/// is found. Never panics on damaged files. +pub fn system_cjk_ui_font(hans: bool, needed: &[char]) -> Option { + let probe = [probe(hans)]; + let needed = if needed.is_empty() { &probe[..] } else { needed.get(..MAX_NEEDED).unwrap_or(needed) }; + find_ui_font(&with_asset_data(crate::system_font_dirs()), &candidates(hans), needed, hans) } -/// The first of `names` (tried in order, each in every folder of `dirs`) that is a font file -/// with a face mapping `probe`. -pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], probe: char, hans: bool) -> Option { - names.iter().flat_map(|n| dirs.iter().map(move |d| d.join(n))).find_map(|path| { - let size = std::fs::metadata(&path).ok().filter(|m| m.is_file())?.len(); - if size > MAX_FILE { - return None; +/// `dirs` and, for each macOS font asset folder among them (`com_apple_MobileAsset_Font`), +/// the `AssetData` folder of every `.asset` in it: macOS ships PingFang there, e.g. +/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font7/.asset/AssetData/PingFang.ttc` +/// (#486). One level, at most [`MAX_ASSETS`] entries per folder. +pub(crate) fn with_asset_data(dirs: Vec) -> Vec { + let mut out = Vec::with_capacity(dirs.len()); + for d in dirs { + let assets = d.file_name().is_some_and(|n| n.to_string_lossy().starts_with("com_apple_MobileAsset_Font")); + if assets && let Ok(entries) = std::fs::read_dir(&d) { + let mut found: Vec<_> = + entries.flatten().take(MAX_ASSETS).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "asset")).collect(); + found.sort(); + out.extend(found.into_iter().map(|p| p.join("AssetData"))); } - let bytes = std::fs::read(&path).ok()?; - let (index, family) = pick_face(&bytes, probe, hans)?; - log::debug!("interface CJK fallback: {family} ({})", path.display()); - Some(SystemUiFont { family, bytes, index }) - }) + out.push(d); + } + out } -/// The face of a font file (or collection) to use: one that maps `probe`, preferring a Regular -/// style and, for Chinese, a Simplified Chinese family (`… SC`, `… GB`, as in a pan-CJK -/// collection). `None` if the header isn't a font's or no face qualifies. -pub(crate) fn pick_face(data: &[u8], probe: char, hans: bool) -> Option<(u32, String)> { +/// The font file among `names` (tried in order, each in every folder of `dirs`) whose best face +/// maps all of `needed`, or failing that the one mapping the most of them (the earliest on a +/// tie). A file that maps none of them is never chosen. +pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed: &[char], hans: bool) -> Option { + let mut tried = std::collections::HashSet::new(); + let mut best: Option<(usize, SystemUiFont)> = None; + for path in names.iter().flat_map(|n| dirs.iter().map(move |d| d.join(n))) { + if !tried.insert(path.clone()) { + continue; + } + let Some(size) = std::fs::metadata(&path).ok().filter(|m| m.is_file()).map(|m| m.len()) else { continue }; + if size > MAX_FILE { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { continue }; + let Some((index, family, covered)) = pick_face(&bytes, needed, hans) else { continue }; + if best.as_ref().is_some_and(|(b, _)| *b >= covered) { + continue; + } + log::debug!("interface CJK fallback: {family} ({}), {covered} of {} characters", path.display(), needed.len()); + best = Some((covered, SystemUiFont { family, bytes, index })); + if covered >= needed.len() { + break; + } + } + best.map(|(_, f)| f) +} + +/// The face of a font file (or collection) to use and how many of `needed` it maps: the one +/// mapping the most, then preferring a Regular style and, for Chinese, a Simplified Chinese +/// family (`… SC`, `… GB`, as in a pan-CJK collection). `None` if the header isn't a font's or +/// no face maps any of `needed`. +pub(crate) fn pick_face(data: &[u8], needed: &[char], hans: bool) -> Option<(u32, String, usize)> { let magic = data.get(..4)?; if ![&[0, 1, 0, 0][..], b"OTTO", b"ttcf"].contains(&magic) { return None; @@ -102,20 +180,22 @@ pub(crate) fn pick_face(data: &[u8], probe: char, hans: bool) -> Option<(u32, St FileRef::Font(_) => 1, FileRef::Collection(c) => c.len().min(MAX_FACES), }; - let mut best: Option<((bool, bool), u32, String)> = None; + let mut best: Option<((usize, bool, bool), u32, String)> = None; for i in 0..count { let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue }; - if f.charmap().map(probe).is_none() { + let charmap = f.charmap(); + let covered = needed.iter().filter(|c| charmap.map(**c).is_some()).count(); + if covered == 0 { continue; } let Some((family, style, _)) = crate::fontdb::face_names(&f) else { continue }; let regional = !hans || family.ends_with(" SC") || family.ends_with(" GB") || family.contains("YaHei"); - let score = (style.eq_ignore_ascii_case("Regular"), regional); + let score = (covered, style.eq_ignore_ascii_case("Regular"), regional); if best.as_ref().is_none_or(|(s, ..)| score > *s) { best = Some((score, i, family)); } } - best.map(|(_, i, family)| (i, family)) + best.map(|((covered, ..), i, family)| (i, family, covered)) } #[cfg(test)] @@ -123,33 +203,55 @@ mod tests { use super::*; #[test] - fn needs_system_cjk_only_when_no_embedded_face_covers_the_language() { - let jp = CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] }; - let sc = CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] }; - // #241: the release embedded only the Japanese faces, and the Chinese interface fell back to them. - assert!(ui_needs_system_cjk(true, &[&jp])); - assert!(!ui_needs_system_cjk(true, &[&sc, &jp])); - assert!(!ui_needs_system_cjk(false, &[&jp])); - assert!(ui_needs_system_cjk(false, &[])); - assert!(ui_needs_system_cjk(true, &[])); + fn needs_system_cjk_when_an_embedded_face_lacks_an_interface_character() { + // A face without CJK glyphs stands in for a craft-fonts face that lacks some (#486). + let latin = CraftFont { family: "Source Sans 3", style: "Regular", scripts: &["Hans"], bytes: crate::bundled()[0] }; + assert_eq!(ui_uncovered_cjk(&["选项 Options", "選項:选项"], &[&latin]), ['选', '選', '項', '项', ':']); + assert!(ui_needs_system_cjk(&["简体中文"], &[])); + assert!(ui_needs_system_cjk(&["Save", "开"], &[&latin])); + // Latin, Cyrillic and symbols are the Latin faces' business. + assert!(!ui_needs_system_cjk(&["Українська Español → ¶"], &[])); + // A damaged embedded face counts as covering nothing; it never panics. + let broken = CraftFont { family: "x", style: "Regular", scripts: &["Hans"], bytes: b"OTTO" }; + assert!(ui_needs_system_cjk(&["页"], &[&broken])); + // The real faces, when built with craft-fonts: the Chinese face covers simplified and + // traditional hanzi the Japanese one lacks (#241, #486). + let embedded = crate::ui_cjk_fonts(true); + if crate::chinese_fonts().next().is_some() { + assert!(!ui_needs_system_cjk(&["页选项另存为啟內"], &embedded)); + } } #[test] - fn finds_the_first_valid_font_covering_the_probe() { + fn finds_the_best_covering_font_also_in_macos_asset_folders() { let dir = std::env::temp_dir().join(format!("wc-uifallback-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); + let assets = dir.join("com_apple_MobileAsset_Font7"); std::fs::create_dir_all(dir.join("sub")).unwrap(); + std::fs::create_dir_all(assets.join("0a1b.asset/AssetData")).unwrap(); + std::fs::create_dir_all(assets.join("not-an-asset/AssetData")).unwrap(); let sans = crate::bundled()[0]; // Missing, damaged and non-font files are skipped without panicking. std::fs::write(dir.join("Deng.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap(); std::fs::write(dir.join("msyh.ttc"), b"not a font").unwrap(); std::fs::write(dir.join("sub/good.ttf"), sans).unwrap(); let names = ["missing.ttf", "Deng.ttf", "msyh.ttc", "sub/good.ttf"]; - let found = find_ui_font(std::slice::from_ref(&dir), &names, 'A', true).unwrap(); + let found = find_ui_font(std::slice::from_ref(&dir), &names, &['A'], true).unwrap(); assert_eq!((found.family.as_str(), found.index, found.bytes.len()), ("Source Sans 3", 0, sans.len())); // No candidate maps a CJK character: nothing is chosen. - assert!(find_ui_font(std::slice::from_ref(&dir), &names, '页', true).is_none()); - assert!(pick_face(b"OTT", 'A', false).is_none()); + assert!(find_ui_font(std::slice::from_ref(&dir), &names, &['页'], true).is_none()); + assert!(pick_face(b"OTT", &['A'], false).is_none()); + // macOS 12 keeps PingFang in `AssetsV2/com_apple_MobileAsset_Font7/.asset/AssetData`. + std::fs::write(assets.join("0a1b.asset/AssetData/PingFang.ttc"), sans).unwrap(); + std::fs::write(assets.join("not-an-asset/AssetData/Hidden.ttc"), sans).unwrap(); + let dirs = with_asset_data(vec![dir.clone(), assets.clone()]); + assert_eq!(dirs, [dir.clone(), assets.join("0a1b.asset/AssetData"), assets.clone()]); + let found = find_ui_font(&dirs, &["PingFang.ttc"], &['A'], true).unwrap(); + assert_eq!(found.family, "Source Sans 3"); + assert!(find_ui_font(&dirs, &["Hidden.ttc"], &['A'], true).is_none()); + // No file maps every needed character: the one mapping the most is used. + let found = find_ui_font(&dirs, &["sub/good.ttf", "PingFang.ttc"], &['A', '页'], true).unwrap(); + assert_eq!(found.family, "Source Sans 3"); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index d00e8e7..7d806bd 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -123,6 +123,14 @@ impl Lang { self.0.name.chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c)) } + /// The CJK interface text the fonts must cover with the Chinese (`hans`) or other CJK fallback + /// order: the catalogs of the CJK languages using that order, and every language's own name (the + /// Options list). The interface fonts check the embedded faces against it (#486). + pub fn cjk_font_text(hans: bool) -> (Vec<&'static str>, String) { + let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans).map(|l| l.0.source).collect(); + (catalogs, Lang::all().map(Lang::name).collect::>().join(" ")) + } + /// A language by its exact code (any case). pub fn from_code(code: &str) -> Option { LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 9ba3396..2818077 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -349,12 +349,44 @@ fn chinese_interface_fonts_load_and_cover_simplified_hanzi() { ctx.set_fonts(crate::theme::font_definitions(true, true)); ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); let covered = ctx.fonts_mut(|f| f.has_glyphs(&egui::FontId::proportional(14.0), "删除页选")); - let embedded = !wordcraft_fonts::ui_needs_system_cjk(true, &wordcraft_fonts::ui_cjk_fonts(true)); - if embedded || wordcraft_fonts::system_cjk_ui_font(true).is_some() { + let (catalogs, _) = Lang::cjk_font_text(true); + let embedded = !wordcraft_fonts::ui_needs_system_cjk(&catalogs, &wordcraft_fonts::ui_cjk_fonts(true)); + if embedded || wordcraft_fonts::system_cjk_ui_font(true, &['页']).is_some() { assert!(covered, "a Chinese face is available but the interface lacks simplified hanzi"); } } +#[test] +fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() { + // #486: every CJK character of the Chinese and Japanese catalogs and of the language names + // has a glyph in the interface font chain built from the embedded faces alone, in every + // interface weight; `?` and boxes showed where the Japanese face was the only CJK one. Needs + // a craft-fonts build (CRAFT_FONTS_DIR); without one there is nothing embedded to check, and + // the interface reads an installed CJK font instead. + if wordcraft_fonts::ui_cjk_fonts(false).is_empty() { + eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)"); + return; + } + let names: String = Lang::all().map(Lang::name).collect(); + for code in ["zh-hans", "zh-hant", "ja"] { + let l = lang(code); + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(l.prefers_hans(), false)); + ctx.run_ui(Default::default(), |_| {}).textures_delta.clear(); + let (entries, errors) = parse_entries(l.0.source); + assert!(errors.is_empty(), "{code}: {errors:?}"); + let chars: std::collections::BTreeSet = + entries.iter().flat_map(|e| e.translation.chars()).chain(names.chars()).filter(|c| wordcraft_fonts::is_cjk(*c)).collect(); + assert!(chars.len() > 300, "{code}: {} CJK characters", chars.len()); + for font in [crate::theme::regular(14.0), crate::theme::medium(14.0), crate::theme::semibold(14.0)] { + let missing: String = ctx.fonts_mut(|f| chars.iter().filter(|c| !f.has_glyph(&font, **c)).collect()); + assert!(missing.is_empty(), "{code}: no glyph in {font:?} for {missing}"); + } + // So the interface reads no installed CJK font for these languages. + assert_eq!(wordcraft_fonts::ui_uncovered_cjk(&[l.0.source, &names], &wordcraft_fonts::ui_cjk_fonts(l.prefers_hans())), []); + } +} + #[test] fn only_cjk_languages_ask_for_an_installed_cjk_font() { // #241 follow-up: an English interface must not read a large system CJK font on startup. diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 997c2d1..2ac7bbe 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -270,10 +270,10 @@ pub fn install_fonts_for(ctx: &egui::Context, prefer_hans: bool) { install_fonts_with(ctx, prefer_hans, false); } -/// [`install_fonts_for`], also adding an installed CJK font when `system_cjk` is set and no -/// embedded face covers the need (#241). Reading that font is a large file read, so callers ask -/// for it only when CJK text is on screen: a CJK interface language, or the language names in -/// Options. +/// [`install_fonts_for`], also adding an installed CJK font after the embedded faces when +/// `system_cjk` is set and the embedded faces leave CJK characters of the interface text without +/// a glyph (#241, #486). Reading that font is a large file read, so callers ask for it only when +/// CJK text is on screen: a CJK interface language, or the language names in Options. pub fn install_fonts_with(ctx: &egui::Context, prefer_hans: bool, system_cjk: bool) { ctx.set_fonts(font_definitions(prefer_hans, system_cjk)); } @@ -311,13 +311,11 @@ pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions // The same static bytes the document fonts use: one copy in the binary. add_cjk(&mut fonts, format!("{} {}", f.family, f.style), FontData::from_static(f.bytes)); } - // No embedded face covers the interface language (a build without craft-fonts, or without - // its Chinese face, #241): an installed CJK font, so the menus don't show boxes. + // The embedded faces leave interface characters without a glyph (a build without craft-fonts, + // without its Chinese face, #241, or with faces lacking catalog characters, #486): an + // installed CJK font after them, so the menus don't show boxes or `?`. #[cfg(not(target_arch = "wasm32"))] - if system_cjk - && wordcraft_fonts::ui_needs_system_cjk(prefer_hans, &cjk) - && let Some(f) = system_cjk_font(prefer_hans) - { + if system_cjk && let Some(f) = system_cjk_font(prefer_hans) { let mut data = FontData::from_static(&f.bytes); data.index = f.index; add_cjk(&mut fonts, format!("system {}", f.family), data); @@ -328,12 +326,29 @@ pub fn font_definitions(prefer_hans: bool, system_cjk: bool) -> FontDefinitions fonts } -/// The installed CJK interface font for a Chinese (`hans`) or other interface, read once. +/// The installed CJK interface font for a Chinese (`hans`) or other interface, chosen once: +/// `None` when the embedded faces already cover the interface text +/// ([`cjk_font_text`](crate::i18n::Lang::cjk_font_text)). The font is picked for the catalogs' +/// missing characters, or for the language names when only those are missing. #[cfg(not(target_arch = "wasm32"))] fn system_cjk_font(hans: bool) -> Option<&'static wordcraft_fonts::SystemUiFont> { static ZH: std::sync::OnceLock> = std::sync::OnceLock::new(); static OTHER: std::sync::OnceLock> = std::sync::OnceLock::new(); - (if hans { &ZH } else { &OTHER }).get_or_init(|| wordcraft_fonts::system_cjk_ui_font(hans)).as_ref() + (if hans { &ZH } else { &OTHER }) + .get_or_init(|| { + let embedded = wordcraft_fonts::ui_cjk_fonts(hans); + let (catalogs, names) = crate::i18n::Lang::cjk_font_text(hans); + let mut needed = wordcraft_fonts::ui_uncovered_cjk(&catalogs, &embedded); + if needed.is_empty() { + needed = wordcraft_fonts::ui_uncovered_cjk(&[&names], &embedded); + if needed.is_empty() { + return None; + } + } + log::debug!("interface: {} CJK characters lack an embedded glyph", needed.len()); + wordcraft_fonts::system_cjk_ui_font(hans, &needed) + }) + .as_ref() } pub fn medium(size: f32) -> FontId { diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 04ed71c..d467f28 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (CJK interface font coverage, #486; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (catalog format, adding a language, clean-room rule): `crates/ui-egui/src/i18n/mod.rs`. @@ -40,7 +40,7 @@ shared items below. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| | English | en | source (100%) | ✅ | Latin ✅ | yes | **full** | 0 | -| Simplified Chinese (Mandarin) | zh-hans | 929 entries; 98% of extracted, ~95% of visible | most; newer panes English | CJK ✅, IME ✅; Windows UI font fixed (#248); no vertical text | no | partial | 3–5 | +| Simplified Chinese (Mandarin) | zh-hans | 929 entries; 98% of extracted, ~95% of visible | most; newer panes English | CJK ✅, IME ✅; Windows UI font fixed (#248); interface font chain covers every catalog character, installed CJK font after the embedded ones when it doesn't, PingFang found on macOS 12 (#486); no vertical text | no | partial | 3–5 | | Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 | | Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 | | Arabic | ar | 0 | ❌ | shaping, joining, lam-alef, bidi ✅ (#207); no kashida; **no mirrored UI** | no | none | 6–8 | @@ -76,6 +76,7 @@ proofing is not included; terminology and checks: [`localization-tr.md`](localiz | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Chinese/Japanese interface fonts: a test checks every catalog character has a glyph; an installed CJK font follows the embedded faces whenever they miss one; macOS font asset folders searched for PingFang (#486) | | 2026-10-10 | minor | Russian interface (#300; 1,524 entries, built-in style names shown translated; covers everything the interface audit finds) | | 2026-10-11 | minor | Turkish interface catalog (1,307 entries), locale selection, saved preference, Turkish casing in command search and font coverage checks; catalog sizes re-counted | | 2026-10-11 | trivial | English spelling (#520): our supplement `assets/spelling/en-extra.txt` adds ~220 common modern words the Moby list lacks ("desktop", "download", "email"…) |