Chinese interface: keep a system CJK fallback after the bundled face; find PingFang on macOS 12 (#486) (#495)

The screenshots in #486 show exactly the characters BIZ UDPGothic (the
Japanese interface face) lacks: 选项规语简户另为导 … in Simplified and
啟內另 … in Traditional, with "?" in the Inter-based medium/semibold
families (no U+25FB replacement glyph) and boxes in the proportional one.
That is the font chain of v0.4.0 and earlier, which embedded no Chinese
face and had no installed-font fallback; v0.5.0 (#248) embeds Noto Sans
CJK SC. This hardens the chain so it can't silently regress:

- The installed CJK font is chosen by coverage: it is added after the
  embedded faces whenever they leave a CJK character of the CJK catalogs
  or the language names without a glyph (not only when no "Hans" face
  exists), and the face picked is the one mapping the most of those
  characters.
- macOS: look inside com_apple_MobileAsset_Font<N>/<hash>.asset/AssetData
  (bounded, one level) where macOS 10.15+ keeps PingFang.ttc, also under
  /System/Library/Assets; PingFangUI.ttc as a last resort.
- Test: every CJK character of the zh-hans, zh-hant and ja catalogs and
  the language names has a glyph in the embedded chain in all three
  interface weights (skips without craft-fonts; fails with a Japanese-only
  craft-fonts, listing exactly the characters from the screenshots).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-11 23:26:23 +02:00 committed by GitHub
parent 73bf2a47b5
commit 2e9d4a22ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 228 additions and 69 deletions

View File

@ -434,8 +434,9 @@ pub fn system_font_dirs() -> Vec<std::path::PathBuf> {
if let Some(h) = &home {
dirs.push(h.join("Library/Fonts"));
}
// Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>.
for parent in ["/System/Library/AssetsV2", "/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs"] {
// Fonts macOS ships as assets (PingFang, Yu Mincho, …): com_apple_MobileAsset_Font<N>
// (`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") {

View File

@ -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`).

View File

@ -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<char> {
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<char> = 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 `<hash>.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<SystemUiFont> {
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<SystemUiFont> {
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<SystemUiFont> {
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<N>`),
/// the `AssetData` folder of every `<hash>.asset` in it: macOS ships PingFang there, e.g.
/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font7/<hash>.asset/AssetData/PingFang.ttc`
/// (#486). One level, at most [`MAX_ASSETS`] entries per folder.
pub(crate) fn with_asset_data(dirs: Vec<std::path::PathBuf>) -> Vec<std::path::PathBuf> {
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<SystemUiFont> {
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"<html>not a font</html>").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/<hash>.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);
}
}

View File

@ -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::<Vec<_>>().join(" "))
}
/// A language by its exact code (any case).
pub fn from_code(code: &str) -> Option<Lang> {
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)

View File

@ -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<char> =
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.

View File

@ -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<Option<wordcraft_fonts::SystemUiFont>> = std::sync::OnceLock::new();
static OTHER: std::sync::OnceLock<Option<wordcraft_fonts::SystemUiFont>> = 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 {

View File

@ -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"…) |