From 58822f6229e898d4d6b4c4f0b1cdb03d1892aba8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Mon, 12 Oct 2026 21:03:15 -0400 Subject: [PATCH] LR-BEHAV-LOCALIZE: strong text is heavier in scripts whose fallback face is a variable font The semibold family had a semibold Inter but, for other scripts, the same regular craft-fonts faces as regular text, so a heading or the chosen tab in Arabic was drawn at regular weight beside bold Latin. A fallback face with a weight axis (today Noto Sans Arabic, 100 to 900) is now registered a second time for the semibold family at weight 600, through epaint's FontTweak::coords. The bytes are borrowed from the embedded font, not copied. Faces with one fixed weight, regular and monospace text, the order of fallbacks and Latin are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/theme.rs | 153 +++++++++++++++++++++++++++++++++++- docs/parity-checklist.md | 2 +- docs/whats-new.md | 6 ++ 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a87a196a..f8657b5c 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -292,6 +292,14 @@ pub fn font_definitions_with_chinese(craft: &'static [lightcraft_engine::CraftFo fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes))); } } + // A variable face holds every weight in its one file: strong text draws it heavier, from the + // same bytes. A face with one fixed weight stays as it is. + for name in &mut bold { + let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) else { continue }; + let Some(heavier) = strong_weight(font.bytes) else { continue }; + *name = format!("{name} strong"); + fonts.font_data.insert(name.clone(), Arc::new(heavier)); + } let script = crate::i18n::language().script(); let prefer_chinese = chinese.is_some() && matches!(script, "Hans" | "Hant"); if let Some(font) = chinese { @@ -339,6 +347,22 @@ pub fn font_credits(chinese_loaded: bool) -> String { families.join(" / ") } +/// The weight of strong text in a variable face, to go with Inter SemiBold. +const STRONG_WEIGHT: f32 = 600.0; + +/// `bytes` as a face drawn at [`STRONG_WEIGHT`], when it is a variable font whose weight axis goes +/// heavier than the weight it draws at by default. The bytes are borrowed, not copied. +fn strong_weight(bytes: &'static [u8]) -> Option { + let face = FontData::from_static(bytes); + let axis = face.variation_axes().into_iter().find(|axis| axis.tag.to_be_bytes() == *b"wght")?; + let weight = STRONG_WEIGHT.min(axis.range.max); + if weight.partial_cmp(&axis.default) != Some(std::cmp::Ordering::Greater) { + return None; + } + let tweak = egui::FontTweak { coords: egui::epaint::text::VariationCoords::new([(axis.tag, weight)]), ..Default::default() }; + Some(face.tweak(tweak)) +} + fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { format!("craft-fonts {} {}", f.family, f.style) } @@ -422,8 +446,11 @@ mod tests { let definitions = font_definitions(lightcraft_engine::CRAFT_FONTS); let name = craft_font_name(thai); assert!(definitions.font_data.contains_key(&name)); + // (The strong family has the face's heavier twin instead when it is a variable font.) + let strong = format!("{name} strong"); 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}"); + let names = definitions.families.get(&family).expect("UI font family"); + assert!(names.contains(&name) || (family == semibold_family() && names.contains(&strong)), "{family:?} falls back on {name}"); } } @@ -505,4 +532,128 @@ mod tests { assert_eq!(names.get(2 + default_count).map(String::as_str), Some(WEB_CHINESE_FONT)); set_language(Locale::En); } + + const ARABIC_WORD: &str = "الصور"; + + fn semibold_family() -> FontFamily { + FontFamily::Name(FONT_SEMIBOLD.into()) + } + + /// The width of `text` in `family` at the body size, and the ink its glyphs put in the font + /// atlas (summed coverage), with `definitions` installed. + fn width_and_ink(definitions: FontDefinitions, text: &str, family: FontFamily) -> (f32, u64) { + let ctx = egui::Context::default(); + ctx.set_fonts(definitions); + let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {}); + frame.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let galley = fonts.layout_no_wrap(text.into(), FontId::new(13.0, family), Color32::WHITE); + let atlas = fonts.image(); + let mut ink = 0_u64; + for glyph in galley.rows.iter().flat_map(|row| row.glyphs.iter()) { + let (min, max) = (glyph.uv_rect.min, glyph.uv_rect.max); + for y in min[1]..max[1] { + for x in min[0]..max[0] { + ink += u64::from(atlas[(usize::from(x), usize::from(y))].a()); + } + } + } + (galley.size().x, ink) + }) + } + + /// The craft-fonts face for Arabic, when it is a variable font with a weight axis. + fn variable_arabic_face() -> Option<&'static lightcraft_engine::CraftFont> { + let face = lightcraft_engine::CRAFT_FONTS.iter().find(|font| font.covers("Arab"))?; + FontData::from_static(face.bytes).variation_axes().iter().any(|axis| axis.tag.to_be_bytes() == *b"wght").then_some(face) + } + + /// Strong text in a script whose fallback face is a variable font is really heavier: an Arabic + /// tab name used to be drawn at regular weight beside a bold Latin one. + #[test] + fn strong_arabic_text_is_heavier_than_regular_with_a_variable_face() { + // Given a build with craft-fonts whose Arabic face has a weight axis + if variable_arabic_face().is_none() { + eprintln!("skipped: built without a variable-weight craft-fonts face for Arabic"); + return; + } + set_language(Locale::En); + // When an Arabic word is laid out regular and strong at the same size + let (regular_width, regular_ink) = width_and_ink(font_definitions(lightcraft_engine::CRAFT_FONTS), ARABIC_WORD, FontFamily::Proportional); + let (strong_width, strong_ink) = width_and_ink(font_definitions(lightcraft_engine::CRAFT_FONTS), ARABIC_WORD, semibold_family()); + eprintln!("{ARABIC_WORD}: regular {regular_width} px, ink {regular_ink}; strong {strong_width} px, ink {strong_ink}"); + // Then the strong one is wider and puts down more ink + assert!(strong_width > regular_width, "strong Arabic is wider than regular: strong {strong_width} px, regular {regular_width} px"); + assert!(strong_ink as f64 > regular_ink as f64 * 1.05, "strong Arabic has more ink than regular: strong {strong_ink}, regular {regular_ink}"); + } + + /// The heavier face is for strong text only: regular and monospace text keep the face as the + /// font file has it, and Latin text keeps Inter in both weights. + #[test] + fn regular_text_and_latin_are_unchanged_by_the_strong_variable_face() { + // Given a build with craft-fonts whose Arabic face has a weight axis + let Some(arabic) = variable_arabic_face() else { + eprintln!("skipped: built without a variable-weight craft-fonts face for Arabic"); + return; + }; + set_language(Locale::En); + // When the UI builds its fonts + let definitions = font_definitions(lightcraft_engine::CRAFT_FONTS); + // Then the regular Arabic face is the font file untouched, and the only Arabic face of + // the regular and monospace families + let name = craft_font_name(arabic); + assert_eq!(definitions.font_data.get(&name).map(|data| &data.tweak), Some(&egui::FontTweak::default())); + let plain_weight = egui::epaint::text::VariationCoords::default(); + for family in [FontFamily::Proportional, FontFamily::Monospace] { + let names = definitions.families.get(&family).expect("UI font family"); + assert!(names.contains(&name), "{family:?} falls back on {name}"); + for other in names.iter().filter(|other| **other != name) { + let coords = definitions.font_data.get(other).map(|data| data.tweak.coords.clone()).unwrap_or_default(); + assert_eq!(coords, plain_weight, "{family:?}: {other} is drawn at the weight its file has"); + } + } + // And regular Arabic text is as wide as with that face alone after Inter + let mut plain = FontDefinitions::default(); + plain.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); + plain.font_data.insert("Arabic".into(), Arc::new(FontData::from_static(arabic.bytes))); + plain.families.insert(FontFamily::Proportional, vec!["Inter".into(), "Arabic".into()]); + let (width, ink) = width_and_ink(definitions.clone(), ARABIC_WORD, FontFamily::Proportional); + assert_eq!((width, ink), width_and_ink(plain, ARABIC_WORD, FontFamily::Proportional)); + // And Latin text, strong and regular, is as it is without craft-fonts + for family in [FontFamily::Proportional, semibold_family()] { + assert_eq!( + width_and_ink(definitions.clone(), "Library", family.clone()), + width_and_ink(font_definitions(&[]), "Library", family.clone()), + "{family:?}" + ); + } + let (regular, _) = width_and_ink(definitions.clone(), "Library", FontFamily::Proportional); + let (strong, _) = width_and_ink(definitions, "Library", semibold_family()); + assert!(strong > regular, "strong Latin stays wider than regular: {strong} px, {regular} px"); + } + + /// A face with one fixed weight is registered once: nothing heavier can be asked of it. Without + /// craft-fonts there is nothing to register at all. + #[test] + fn a_static_face_has_no_strong_twin() { + // Given a face without a weight axis for another script + static FACES: &[lightcraft_engine::CraftFont] = &[lightcraft_engine::CraftFont { + family: "Static Sans", + style: "Regular", + scripts: &["Thai"], + bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), + }]; + set_language(Locale::En); + for craft in [FACES, &[]] { + // When the UI builds its fonts + let definitions = font_definitions(craft); + // Then no face is drawn at another weight than its file has, and the strong family + // holds what the regular one does after Inter + assert!(definitions.font_data.values().all(|data| data.tweak.coords == egui::epaint::text::VariationCoords::default())); + let regular = definitions.families.get(&FontFamily::Proportional).expect("regular family"); + let strong = definitions.families.get(&semibold_family()).expect("strong family"); + assert_eq!(regular.get(1..), strong.get(1..)); + assert_eq!(definitions.font_data.len(), FontDefinitions::default().font_data.len() + 2 + craft.len()); + } + } } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4..7a7ef9f3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -474,7 +474,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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; 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-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 (a variable face, as for Arabic, is drawn heavier for bold from its one file); 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 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a..e8df5978 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,11 @@ # What's new in LightCraft +## 2026-10-12 + +### Bold text in other scripts +- Arabic names and other Arabic text in headings, chosen tabs and other bold places are now drawn bold, as Latin text + beside them is; they used to stay at regular weight. + ## 2026-10-11 ### JPEG XL export