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:
parent
73bf2a47b5
commit
2e9d4a22ce
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"…) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue