From d803d905b4536c71f29d19a5668108a9ffd6a036 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Sat, 10 Oct 2026 09:57:14 -0400 Subject: [PATCH] LR-BEHAV-LOCALIZE: craft-fonts faces of other scripts are UI fallbacks (#736) Thai folder and file names drew boxes: the UI fell back only on craft-fonts' CJK faces, so a Thai face (craft-fonts #15) or the Arabic one already there was never used. fonts::ui_fallback keeps the CJK faces first, in their per-language order, then appends every face of another non-Latin script, whatever the interface language (a Latin-only face is no fallback: Inter covers Latin). theme.rs builds the Proportional, SemiBold and Monospace families from it. Tests: the fallback order on synthetic faces; with a craft-fonts build that has a Thai face, every UI family falls back on it, and the shipped Thai and Arabic faces have their script's glyphs. --- CLAUDE.md | 2 +- crates/engine/src/fonts.rs | 49 +++++++++++++++++++++++++++++++++---- crates/ui-egui/src/theme.rs | 19 +++++++++++++- docs/parity.md | 2 +- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 814bc5cf..6552323a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ of it. Full standard: `../craftrules/standards/never-crash.md` then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft` (or any cargo/xtask command). `crates/engine/build.rs` embeds the manifest's fonts as `lightcraft_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web` ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI - (`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset, + (`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter; the UI also falls back on the faces of other scripts (Thai, Arabic, …). Unset, `CRAFT_FONTS` is empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build with it (`release.yml`, `CRAFT_FONTS_REQUIRED=1`) and ship the fonts' OFL licences. Tests that need these fonts skip without it; the FreeBSD CI job runs them with it. Rules: craftrules diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 97a83063..56f272ad 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -51,11 +51,22 @@ pub fn is_cjk_script(script: &str) -> bool { matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang") } -/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script -/// first (for `style` when asked), then every other CJK face. One CJK face list serves every -/// language, but Han characters are shared between them: a Chinese reader must get the Chinese -/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first. -/// Empty without craft-fonts. +/// The craft-fonts faces to fall back on for `script`, in preference order: the CJK faces, those of +/// that script first (for `style` when asked), then the faces of every other non-Latin script +/// (Thai, Arabic, ...), which file and folder names can contain whatever the interface language. +/// One CJK face list serves every language, but Han characters are shared between them: a Chinese +/// reader must get the Chinese forms and a Japanese reader the Japanese ones, so the active +/// language's own script comes first. The other scripts share no characters, so their order only +/// prefers `style`. Empty without craft-fonts. +pub fn ui_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> { + let mut faces = cjk_fallback(fonts, script, style); + let mut others: Vec<&CraftFont> = fonts.iter().filter(|font| !covers_cjk(font) && font.scripts.iter().any(|s| *s != "Latn")).collect(); + others.sort_by_key(|font| font.style != style); + faces.extend(others); + faces +} + +/// The CJK part of [`ui_fallback`], in the same preference order. pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> { let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect(); faces.sort_by_key(|font| { @@ -92,6 +103,13 @@ mod tests { assert!(ab_glyph::FontRef::try_from_slice(f.bytes).is_ok(), "{} {} parses", f.family, f.style); } assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces"); + // Every script face that ships is a UI fallback, and has the glyphs of its script (#736). + use ab_glyph::Font; + for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة")] { + let Some(face) = ui_fallback(CRAFT_FONTS, "Latn", "Regular").into_iter().find(|f| f.covers(script)) else { continue }; + let font = ab_glyph::FontRef::try_from_slice(face.bytes).expect("parses"); + assert!(text.chars().all(|c| font.glyph_id(c).0 != 0), "{} draws {script}", face.family); + } } /// Each language's own faces come first, so shared Han keeps the language's forms, and Latin @@ -112,4 +130,25 @@ mod tests { assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3); assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0); } + + /// Faces for other scripts (#736: Thai folder names showed boxes) follow the CJK faces in the + /// UI fallback whatever the language; a Latin-only face is not a fallback (Inter covers Latin). + #[test] + fn other_scripts_follow_the_cjk_faces() { + static FACES: &[CraftFont] = &[ + CraftFont { family: "Noto Sans Thai", style: "Regular", scripts: &["Thai", "Latn"], bytes: &[] }, + CraftFont { family: "Source Sans 3", style: "Regular", scripts: &["Latn"], bytes: &[] }, + CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] }, + CraftFont { family: "Noto Sans Arabic", style: "Bold", scripts: &["Arab"], bytes: &[] }, + CraftFont { family: "Noto Sans Arabic", style: "Regular", scripts: &["Arab"], bytes: &[] }, + ]; + let families = |script: &str, style: &str| ui_fallback(FACES, script, style).iter().map(|f| (f.family, f.style)).collect::>(); + assert_eq!( + families("Latn", "Regular"), + [("Noto Sans CJK SC", "Regular"), ("Noto Sans Thai", "Regular"), ("Noto Sans Arabic", "Regular"), ("Noto Sans Arabic", "Bold")] + ); + assert_eq!(families("Hans", "Bold")[0], ("Noto Sans CJK SC", "Regular")); + assert_eq!(families("Hans", "Bold")[1], ("Noto Sans Arabic", "Bold")); + assert!(ui_fallback(&[], "Thai", "Regular").is_empty()); + } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0d7e1570..16e3254e 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -153,7 +153,7 @@ pub fn font_definitions_with_chinese(craft: &'static [lightcraft_engine::CraftFo fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")))); // Craft-fonts faces in preference order for a family drawn in `style`. let fallback = |style: &str| { - lightcraft_engine::fonts::cjk_fallback(craft, crate::i18n::language().script(), style) + lightcraft_engine::fonts::ui_fallback(craft, crate::i18n::language().script(), style) .into_iter() .map(craft_font_name) .collect::>() @@ -269,6 +269,23 @@ mod tests { use super::*; use crate::i18n::{Locale, set_language}; + /// #736: a Thai folder name drew boxes because only CJK craft-fonts faces were fallbacks. With a + /// craft-fonts build that carries a Thai face, an English UI offers it in every family. + #[test] + fn thai_face_is_a_fallback_in_every_ui_family_when_craft_fonts_carry_one() { + let Some(thai) = lightcraft_engine::CRAFT_FONTS.iter().find(|f| f.covers("Thai")) else { + eprintln!("no Thai face in craft-fonts: nothing to check"); + return; + }; + set_language(Locale::En); + let definitions = font_definitions(lightcraft_engine::CRAFT_FONTS); + let name = craft_font_name(thai); + assert!(definitions.font_data.contains_key(&name)); + for family in [FontFamily::Proportional, FontFamily::Name(FONT_SEMIBOLD.into()), FontFamily::Monospace] { + assert!(definitions.families.get(&family).is_some_and(|names| names.contains(&name)), "{family:?} falls back on {name}"); + } + } + #[test] fn native_chinese_face_falls_back_after_default_faces() { static FACES: &[lightcraft_engine::CraftFont] = &[ diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c..cde64f94 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -533,7 +533,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | | LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |