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.
This commit is contained in:
radialmonster 2026-10-10 09:57:14 -04:00
parent b70ae1741e
commit d803d905b4
4 changed files with 64 additions and 8 deletions

View File

@ -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

View File

@ -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::<Vec<_>>();
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());
}
}

View File

@ -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::<Vec<String>>()
@ -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] = &[

View File

@ -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 | ⬜ | | |