mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
commit
18bd8fd881
3 files changed
+158
-2
No files matched your search
+152
-1
@@ -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<FontData> {
|
||||
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)
|
||||
}
|
||||
@@ -450,8 +474,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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,4 +560,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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -481,7 +481,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/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab. Canvas handles, spot and mask placement are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) |
|
||||
| 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 | ⬜ | | |
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
read from the white-balance presets the camera stores in every ARW: a photo shot at 3400 K on the camera reads
|
||||
3396 K, one on the Shade preset 7493 K. As for #730, only the numbers change: edits made on the old relative
|
||||
scale keep their look, and As Shot shows the real temperature once the file is opened.
|
||||
## 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
|
||||
|
||||
|
||||
Reference in new issue
Block a user