From 6beb80ed367d3bd9a0bb29ba388f25a1b47663b8 Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Fri, 9 Oct 2026 17:06:36 +0530 Subject: [PATCH] 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"); + } } }