From 891514892aeb377c39732d09ddfa5053f834cfee Mon Sep 17 00:00:00 2001 From: justinjohn0306 <34035011+justinjohn0306@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:13:44 +0530 Subject: [PATCH 1/3] CJK text renders as tofu: fall back to a system CJK font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Japanese (or Chinese/Korean) workbook opened fine — the engine reads the strings correctly — but every kanji and kana drew as a .notdef box, because egui's bundled fonts carry no CJK coverage and nothing added a CJK face to the fallback chain. Append a system CJK face to every font family (UI, cell, serif, mono): a Japanese face first for correct kana/JIS kanji, a Chinese face for breadth, and a Hangul face for Korean. epaint picks the first face in a family that has the glyph, so Latin still renders from the Latin fonts and only Han/Kana/Hangul reach the CJK fonts. No font bytes are bundled (AGENTS.md): families are loaded from the OS, and a machine with no CJK font simply keeps the old behaviour. Tests: the CJK keys end every family chain (Latin stays first); and the proportional family resolves 「カテゴリ」 to real glyphs, not the replacement character — verified to fail without the fallback. --- crates/ui-egui/src/theme.rs | 101 ++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 2dbd8df7b..e14b9f880 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -292,6 +292,9 @@ pub fn font_definitions() -> FontDefinitions { let mut fonts = FontDefinitions::default(); let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); + // CJK has to sit at the end of each chain: a Latin font owns Latin, and epaint picks the first + // face that has the glyph, so the CJK faces only ever catch Han/Kana/Hangul. + let cjk = load_cjk(&mut fonts); for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { let paths = candidates(role); let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); @@ -306,6 +309,7 @@ pub fn font_definitions() -> FontDefinitions { chain.push(format!("sys-{CELL}")); } chain.extend(if role == MONO { base_mono.clone() } else { base_prop.clone() }); + chain.extend(cjk.iter().cloned()); chain.retain(|k| fonts.font_data.contains_key(k)); fonts.families.insert(FontFamily::Name(role.into()), chain); } @@ -316,6 +320,65 @@ pub fn font_definitions() -> FontDefinitions { fonts } +/// Loads the system CJK faces (Han/Kana and Hangul, one file each) and returns their keys so every +/// family can carry them as fallback. egui's bundled fonts have no CJK coverage, so without this a +/// Japanese or Chinese workbook renders as tofu boxes even though the text was read correctly. +#[cfg(not(target_arch = "wasm32"))] +fn load_cjk(fonts: &mut FontDefinitions) -> Vec { + let mut keys = Vec::new(); + for (key, cands) in [("sys-cjk-han", cjk_han()), ("sys-cjk-hangul", cjk_hangul())] { + let refs: Vec<(&str, u32)> = cands.iter().map(|(p, i)| (p.as_str(), *i)).collect(); + if let Some(fd) = try_load(&refs) { + fonts.font_data.insert(key.to_string(), Arc::new(fd)); + keys.push(key.to_string()); + } + } + keys +} + +#[cfg(target_arch = "wasm32")] +fn load_cjk(_fonts: &mut FontDefinitions) -> Vec { + Vec::new() +} + +/// Han + Kana faces, in fallback order. A Japanese face comes first (kana and JIS kanji render +/// with Japanese glyph shapes); a Chinese face follows to widen coverage. Every CJK face carries +/// kana, so any of them renders Japanese; the order only decides kanji glyph style. +#[cfg(not(target_arch = "wasm32"))] +fn cjk_han() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let lin = ["/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic (Japanese) + v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic (Japanese) + v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei (Chinese) + v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); + v.push((format!("{mac}/PingFang.ttc"), 0)); + for d in lin { + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); + v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0)); + v.push((format!("{d}/wqy-microhei.ttc"), 0)); + } + v +} + +/// Hangul faces (Korean; not covered by the Han fonts). +#[cfg(not(target_arch = "wasm32"))] +fn cjk_hangul() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype"]; + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic + v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0)); + for d in lin { + v.push((format!("{d}/NotoSansCJKkr-Regular.otf"), 0)); + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); + } + v +} + pub fn apply(ctx: &egui::Context, dark: bool) { let t = if dark { Tokens::dark() } else { Tokens::light() }; let mut v = if dark { Visuals::dark() } else { Visuals::light() }; @@ -351,3 +414,41 @@ pub fn apply(ctx: &egui::Context, dark: bool) { pub fn color32(rgb: [u8; 3]) -> Color32 { Color32::from_rgb(rgb[0], rgb[1], rgb[2]) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + #[test] + fn cjk_fallback_is_appended_to_every_family() { + // Structural: whatever loaded, the CJK keys must sit at the end of each chain (after the + // Latin/default fonts) so Latin still wins for Latin text. + let fonts = font_definitions(); + let cjk_keys: Vec = + ["sys-cjk-han", "sys-cjk-hangul"].into_iter().filter(|k| fonts.font_data.contains_key(*k)).map(str::to_string).collect(); + if cjk_keys.is_empty() { + return; // no CJK face on this machine (minimal Linux) + } + for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { + let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); + assert!(chain.len() >= cjk_keys.len(), "{role} chain too short for its CJK fallback"); + let tail = &chain[chain.len() - cjk_keys.len()..]; + assert_eq!(tail, cjk_keys.as_slice(), "{role} must end its chain with the CJK faces"); + } + } + + #[test] + fn cjk_fallback_covers_japanese() { + // Real render check: the proportional family (what cells and the UI use) must resolve + // Japanese kana/kanji to a glyph, not the tofu replacement char. + if cjk_han().iter().all(|(p, _)| std::fs::read(p).is_err()) { + return; // no CJK face on this machine (minimal Linux); nothing to assert + } + let ctx = egui::Context::default(); + ctx.set_fonts(font_definitions()); + let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test + let covered = ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(14.0), "カテゴリ")); + assert!(covered, "Japanese text must render with a real glyph, not tofu"); + } +} From 6beb80ed367d3bd9a0bb29ba388f25a1b47663b8 Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Fri, 9 Oct 2026 17:06:36 +0530 Subject: [PATCH 2/3] Fonts: render Thai and Hebrew (and any non-Latin) instead of tofu (issue #32) egui bundles only Latin glyphs, and the fallback chain appended just the Han/Kana and Hangul faces, so Thai and Hebrew text rendered as tofu boxes even though the text was read correctly. Generalise the CJK fallback into a per-script fallback: every family now also carries a Thai face (Leelawadee UI / Tahoma / Thonburi / Noto Sans Thai) and a Hebrew face (David / Arial Hebrew / Noto Sans Hebrew). epaint shapes the complex scripts itself via harfrust -- it only needed a face that has the glyphs. The faces stay at the end of each chain, so Latin still wins for Latin text. The tests become script-general: a structural check that every family ends with the loaded fallback faces, and a per-script render check that the sample string resolves to a real glyph when the face is present. Full bidi reordering of mixed LTR/RTL text is a separate epaint limitation (no Unicode Bidi Algorithm); a pure other-script run is shaped correctly. --- crates/ui-egui/src/theme.rs | 119 +++++++++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index e14b9f880..31f93ac58 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -292,9 +292,9 @@ pub fn font_definitions() -> FontDefinitions { let mut fonts = FontDefinitions::default(); let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); - // CJK has to sit at the end of each chain: a Latin font owns Latin, and epaint picks the first - // face that has the glyph, so the CJK faces only ever catch Han/Kana/Hangul. - let cjk = load_cjk(&mut fonts); + // The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin, + // and epaint picks the first face that has the glyph, so they only catch their own scripts. + let fallback = load_fallback(&mut fonts); for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { let paths = candidates(role); let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); @@ -309,7 +309,7 @@ pub fn font_definitions() -> FontDefinitions { chain.push(format!("sys-{CELL}")); } chain.extend(if role == MONO { base_mono.clone() } else { base_prop.clone() }); - chain.extend(cjk.iter().cloned()); + chain.extend(fallback.iter().cloned()); chain.retain(|k| fonts.font_data.contains_key(k)); fonts.families.insert(FontFamily::Name(role.into()), chain); } @@ -320,13 +320,15 @@ pub fn font_definitions() -> FontDefinitions { fonts } -/// Loads the system CJK faces (Han/Kana and Hangul, one file each) and returns their keys so every -/// family can carry them as fallback. egui's bundled fonts have no CJK coverage, so without this a -/// Japanese or Chinese workbook renders as tofu boxes even though the text was read correctly. +/// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so +/// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so +/// without this a Thai, Hebrew, Japanese or Chinese workbook renders as tofu boxes even though the +/// text was read correctly. epaint shapes the complex scripts itself (via `harfrust`); it only needs +/// a face that has the glyphs. #[cfg(not(target_arch = "wasm32"))] -fn load_cjk(fonts: &mut FontDefinitions) -> Vec { +fn load_fallback(fonts: &mut FontDefinitions) -> Vec { let mut keys = Vec::new(); - for (key, cands) in [("sys-cjk-han", cjk_han()), ("sys-cjk-hangul", cjk_hangul())] { + for (key, cands) in fallback_faces() { let refs: Vec<(&str, u32)> = cands.iter().map(|(p, i)| (p.as_str(), *i)).collect(); if let Some(fd) = try_load(&refs) { fonts.font_data.insert(key.to_string(), Arc::new(fd)); @@ -337,10 +339,18 @@ fn load_cjk(fonts: &mut FontDefinitions) -> Vec { } #[cfg(target_arch = "wasm32")] -fn load_cjk(_fonts: &mut FontDefinitions) -> Vec { +fn load_fallback(_fonts: &mut FontDefinitions) -> Vec { Vec::new() } +/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script +/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one +/// file. Order only matters between faces that both cover a glyph (e.g. Japanese vs Chinese kanji). +#[cfg(not(target_arch = "wasm32"))] +fn fallback_faces() -> Vec<(&'static str, Vec<(String, u32)>)> { + vec![("sys-cjk-han", cjk_han()), ("sys-cjk-hangul", cjk_hangul()), ("sys-thai", thai()), ("sys-hebrew", hebrew())] +} + /// Han + Kana faces, in fallback order. A Japanese face comes first (kana and JIS kanji render /// with Japanese glyph shapes); a Chinese face follows to widen coverage. Every CJK face carries /// kana, so any of them renders Japanese; the order only decides kanji glyph style. @@ -379,6 +389,51 @@ fn cjk_hangul() -> Vec<(String, u32)> { v } +/// Thai faces. Windows ships Leelawadee UI and Tahoma/Microsoft Sans Serif (both carry Thai); +/// macOS has Thonburi; Linux usually has Noto Sans Thai or Garuda. +#[cfg(not(target_arch = "wasm32"))] +fn thai() -> Vec<(String, u32)> { + let mac_sys = "/System/Library/Fonts"; + let mac_sup = "/System/Library/Fonts/Supplemental"; + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\LeelawUI.ttf"), 0)); // Leelawadee UI + v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma + v.push((format!("{win}\\micross.ttf"), 0)); // Microsoft Sans Serif + v.push((format!("{mac_sup}/Thonburi.ttc"), 0)); + v.push((format!("{mac_sys}/SukhumvitSet.ttc"), 0)); + for d in lin { + v.push((format!("{d}/NotoSansThai-Regular.ttf"), 0)); + v.push((format!("{d}/tlwg/Garuda.ttf"), 0)); + v.push((format!("{d}/garuda/Garuda.ttf"), 0)); + } + v +} + +/// Hebrew faces. Windows has David and FrankRuehl (and Tahoma/Courier New carry Hebrew); macOS has +/// Arial Hebrew and David; Linux usually has Noto Sans Hebrew or DejaVu Sans. +#[cfg(not(target_arch = "wasm32"))] +fn hebrew() -> Vec<(String, u32)> { + let mac_sys = "/System/Library/Fonts"; + let mac_sup = "/System/Library/Fonts/Supplemental"; + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\david.ttf"), 0)); // David + v.push((format!("{win}\\frank.ttf"), 0)); // FrankRuehl + v.push((format!("{win}\\tahoma.ttf"), 0)); // Tahoma + v.push((format!("{mac_sup}/Arial Hebrew.ttf"), 0)); + v.push((format!("{mac_sup}/David.ttf"), 0)); + v.push((format!("{mac_sys}/Lucida Grande.ttc"), 0)); + for d in lin { + v.push((format!("{d}/NotoSansHebrew-Regular.ttf"), 0)); + v.push((format!("{d}/dejavu/DejaVuSans.ttf"), 0)); + v.push((format!("{d}/freefont/FreeSans.ttf"), 0)); + } + v +} + pub fn apply(ctx: &egui::Context, dark: bool) { let t = if dark { Tokens::dark() } else { Tokens::light() }; let mut v = if dark { Visuals::dark() } else { Visuals::light() }; @@ -420,35 +475,43 @@ mod tests { use super::*; #[test] - fn cjk_fallback_is_appended_to_every_family() { - // Structural: whatever loaded, the CJK keys must sit at the end of each chain (after the - // Latin/default fonts) so Latin still wins for Latin text. + fn fallback_faces_are_appended_to_every_family() { + // Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain + // (after the Latin/default fonts) so Latin still wins for Latin text. let fonts = font_definitions(); - let cjk_keys: Vec = - ["sys-cjk-han", "sys-cjk-hangul"].into_iter().filter(|k| fonts.font_data.contains_key(*k)).map(str::to_string).collect(); - if cjk_keys.is_empty() { - return; // no CJK face on this machine (minimal Linux) + let fb_keys: Vec = fallback_faces().into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); + if fb_keys.is_empty() { + return; // no fallback face on this machine (minimal Linux) } for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); - assert!(chain.len() >= cjk_keys.len(), "{role} chain too short for its CJK fallback"); - let tail = &chain[chain.len() - cjk_keys.len()..]; - assert_eq!(tail, cjk_keys.as_slice(), "{role} must end its chain with the CJK faces"); + assert!(chain.len() >= fb_keys.len(), "{role} chain too short for its fallback"); + let tail = &chain[chain.len() - fb_keys.len()..]; + assert_eq!(tail, fb_keys.as_slice(), "{role} must end its chain with the fallback faces"); } } #[test] - fn cjk_fallback_covers_japanese() { - // Real render check: the proportional family (what cells and the UI use) must resolve - // Japanese kana/kanji to a glyph, not the tofu replacement char. - if cjk_han().iter().all(|(p, _)| std::fs::read(p).is_err()) { - return; // no CJK face on this machine (minimal Linux); nothing to assert - } + fn fallback_covers_its_script() { + // Real render check, per script: the proportional family (what cells and the UI use) must + // resolve a sample string to a real glyph, not the tofu replacement char. Skips a script + // whose face is absent (a minimal machine) and fails if a face is present but not wired in. + let cases: [(&str, Vec<(String, u32)>, &str); 4] = [ + ("sys-cjk-han", cjk_han(), "カテゴリ"), + ("sys-cjk-hangul", cjk_hangul(), "한글"), + ("sys-thai", thai(), "ทดสอบ"), + ("sys-hebrew", hebrew(), "עברית"), + ]; let ctx = egui::Context::default(); ctx.set_fonts(font_definitions()); let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); out.textures_delta.clear(); // nothing consumes the atlas in a headless test - let covered = ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(14.0), "カテゴリ")); - assert!(covered, "Japanese text must render with a real glyph, not tofu"); + for (key, cands, sample) in cases { + if cands.iter().all(|(p, _)| std::fs::read(p).is_err()) { + continue; // no face for this script on this machine; nothing to assert + } + let covered = ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(14.0), sample)); + assert!(covered, "{key} is present but {sample:?} still renders as tofu"); + } } } From c4fd49afc2b7129fd59da0f309ec6a14a061a5b9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:22:11 -0400 Subject: [PATCH 3/3] Review fix-up: dedupe CJK fallback load, wider Linux/macOS font paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load each fallback font file once: the region-merged Noto Sans CJK .ttc found for Han also covers Hangul (and Tahoma covers Thai and Hebrew), so a later script whose candidate file is already loaded reuses it instead of loading tens of MB again. New test: no two fallback faces share a file. - Fold in the Linux CJK paths from #103: Fedora google-noto-cjk/, Arch noto-cjk/ and wenquanyi/, Debian truetype/wqy/wqy-microhei.ttc, the SC .otf builds, /usr/local, plus SimSun on Windows. Document the .ttc face indices (JP 0, KR 1, SC 2, TC 3, HK 4); Hangul uses the KR face. - macOS: Hiragino Sans (ヒラギノ角ゴシック W3) and Hiragino Sans GB come before PingFang.ttc, which recent macOS no longer ships in /System. - Comments promise glyphs instead of tofu, not correct rendering. - fallback_covers_its_script compared has_glyphs, which is a false negative when the owning face also draws the replacement glyph (DejaVu Sans as UI font owns Hebrew); compare laid-out glyphs with the tofu glyph instead. Co-authored-by: eyouhi <47080137+eyouhi@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/theme.rs | 94 ++++++++++++++++++++++++++++++------- 1 file changed, 76 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 1e8e8a70d..b10842757 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -322,17 +322,27 @@ pub fn font_definitions() -> FontDefinitions { /// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so /// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so -/// without this a Thai, Hebrew, Japanese or Chinese workbook renders as tofu boxes even though the -/// text was read correctly. epaint shapes the complex scripts itself (via `harfrust`); it only needs -/// a face that has the glyphs. +/// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text +/// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing +/// is bundled, and a machine without the face keeps the old behaviour. #[cfg(not(target_arch = "wasm32"))] fn load_fallback(fonts: &mut FontDefinitions) -> Vec { let mut keys = Vec::new(); + // Files already loaded as a fallback face. A file serves at most one key: the region-merged + // Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and + // Hebrew), so loading it again for a later script would only duplicate tens of MB in memory. + let mut loaded: Vec = Vec::new(); for (key, cands) in fallback_faces() { - let refs: Vec<(&str, u32)> = cands.iter().map(|(p, i)| (p.as_str(), *i)).collect(); - if let Some(fd) = try_load(&refs) { - fonts.font_data.insert(key.to_string(), Arc::new(fd)); - keys.push(key.to_string()); + for (path, index) in &cands { + if loaded.contains(path) { + break; // a face loaded for an earlier script already covers this one + } + if let Some(fd) = try_load(&[(path.as_str(), *index)]) { + fonts.font_data.insert(key.to_string(), Arc::new(fd)); + keys.push(key.to_string()); + loaded.push(path.clone()); + break; + } } } keys @@ -351,40 +361,66 @@ fn fallback_faces() -> Vec<(&'static str, Vec<(String, u32)>)> { vec![("sys-cjk-han", cjk_han()), ("sys-cjk-hangul", cjk_hangul()), ("sys-thai", thai()), ("sys-hebrew", hebrew())] } +/// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, +/// `truetype/wqy`), Fedora (`google-noto-cjk`), Arch (`noto-cjk`, `wenquanyi`), plus local and +/// flat layouts. +#[cfg(not(target_arch = "wasm32"))] +const LINUX_CJK_DIRS: [&str; 10] = [ + "/usr/share/fonts/opentype/noto", + "/usr/share/fonts/google-noto-cjk", + "/usr/share/fonts/noto-cjk", + "/usr/share/fonts/truetype/noto", + "/usr/share/fonts/noto", + "/usr/share/fonts/truetype", + "/usr/share/fonts/TTF", + "/usr/share/fonts", + "/usr/local/share/fonts", + "/usr/local/share/fonts/noto-cjk", +]; + /// Han + Kana faces, in fallback order. A Japanese face comes first (kana and JIS kanji render /// with Japanese glyph shapes); a Chinese face follows to widen coverage. Every CJK face carries /// kana, so any of them renders Japanese; the order only decides kanji glyph style. +/// +/// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3, +/// HK 4) with the same glyph coverage; index 0 picks the Japanese glyph shapes. #[cfg(not(target_arch = "wasm32"))] fn cjk_han() -> Vec<(String, u32)> { let mac = "/System/Library/Fonts"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; let mut v: Vec<(String, u32)> = Vec::new(); v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic (Japanese) v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic (Japanese) v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei (Chinese) - v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); - v.push((format!("{mac}/PingFang.ttc"), 0)); - for d in lin { + v.push((format!("{win}\\simsun.ttc"), 0)); // SimSun (Chinese, older installs) + v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans (Japanese) + v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); // ships on every recent macOS + v.push((format!("{mac}/PingFang.ttc"), 0)); // older macOS only; now a downloadable asset + for d in LINUX_CJK_DIRS { v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0)); + v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0)); + } + for d in LINUX_CJK_DIRS { + v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0)); + v.push((format!("{d}/wenquanyi/wqy-microhei.ttc"), 0)); v.push((format!("{d}/wqy-microhei.ttc"), 0)); } v } -/// Hangul faces (Korean; not covered by the Han fonts). +/// Hangul faces (Korean). The Han faces above that come from the region-merged Noto Sans CJK file +/// already carry Hangul; [`load_fallback`] then skips this list rather than load the file twice. #[cfg(not(target_arch = "wasm32"))] fn cjk_hangul() -> Vec<(String, u32)> { let mac = "/System/Library/Fonts"; let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/truetype/noto", "/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype"]; let mut v: Vec<(String, u32)> = Vec::new(); v.push((format!("{win}\\malgun.ttf"), 0)); // Malgun Gothic v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0)); - for d in lin { + for d in LINUX_CJK_DIRS { v.push((format!("{d}/NotoSansCJKkr-Regular.otf"), 0)); - v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 1)); // KR face } v } @@ -497,6 +533,19 @@ mod tests { } } + #[test] + fn fallback_faces_load_each_file_once() { + // The region-merged Noto Sans CJK .ttc serves Han and Hangul; it must be loaded once, not + // once per script (tens of MB each). + let fonts = font_definitions(); + let present: Vec<&Arc> = fallback_faces().into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); + for (i, a) in present.iter().enumerate() { + for b in present.iter().skip(i + 1) { + assert!(a.font != b.font, "the same font file is loaded for two fallback scripts"); + } + } + } + #[test] fn fallback_covers_its_script() { // Real render check, per script: the proportional family (what cells and the UI use) must @@ -512,12 +561,21 @@ mod tests { ctx.set_fonts(font_definitions()); let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); out.textures_delta.clear(); // nothing consumes the atlas in a headless test + // `has_glyphs` is a false negative when the face that owns the script is also the face that + // draws the replacement glyph (DejaVu Sans as the UI font owns Hebrew), so compare the laid + // out glyphs with the tofu glyph itself instead. + let font = FontId::proportional(14.0); + let glyphs = |text: &str| { + let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK)); + galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.uv_rect)).collect::>() + }; + let tofu = glyphs("\u{25FB}"); for (key, cands, sample) in cases { - if cands.iter().all(|(p, _)| std::fs::read(p).is_err()) { + if !cands.iter().any(|(p, _)| std::path::Path::new(p).exists()) { continue; // no face for this script on this machine; nothing to assert } - let covered = ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(14.0), sample)); - assert!(covered, "{key} is present but {sample:?} still renders as tofu"); + let drawn = glyphs(sample); + assert!(!drawn.is_empty() && drawn.iter().all(|g| !tofu.contains(g)), "{key} is present but {sample:?} still renders as tofu"); } } }