Merge pull request #184 from lzjtugjc/i18n/zh-hans

LR-BEHAV-LOCALIZE: a language table, Simplified Chinese, and per-script font fallback
This commit is contained in:
Brandon Thomas 2026-10-07 07:06:42 -04:00 committed by GitHub
commit f0bb7c768e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1958 additions and 203 deletions

View File

@ -334,14 +334,14 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
```
**Japanese text** needs the shared font repo, an optional build input (official releases always include it):
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
Without it LightCraft builds and runs the same, but Japanese text has no glyphs. Fonts are never committed to this
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
@ -432,7 +432,7 @@ Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
Inter (SIL OFL 1.1). Builds made with [craft-fonts](https://github.com/storytold/craft-fonts) (all official releases)
also embed its Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
also embed its Chinese and Japanese fonts (Noto Sans CJK SC, BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). All icons are original.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the

View File

@ -29,7 +29,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Chinese/Japanese/English UI (see docs/localization.md); remaining technical errors and date formats; accessibility partial; headless UI tests time out under machine load |
### By kind of user
@ -87,7 +87,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Japanese/English localisation 🟡; accessibility and other locales ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Chinese/Japanese/English localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
@ -192,12 +192,14 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
tracker rows for camera colour, camera coverage and render fidelity.
## Japanese interface and text watermarks
## Chinese and Japanese interfaces, and text watermarks
English/Japanese interface language is persisted in UI state. Core menus have Japanese
translations; untranslated panels and dialogs retain English. Japanese glyphs (UI: BIZ UDPGothic;
watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made with the
optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
English, Simplified Chinese and Japanese interface languages are persisted in UI state, and the
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
characters stay upright in top-to-bottom columns, with newlines starting columns to the
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical

View File

@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
out.push(';');
}
}
let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese());
let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code());
for (t, items) in bar {
s.push_str(t);
walk(items, &mut s);

View File

@ -245,14 +245,15 @@ impl Default for Watermark {
/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md).
static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
/// The watermark faces: Inter first, then the craft-fonts Japanese faces (Mincho first, the
/// watermark's serif look; then any other Japanese face). Without craft-fonts that is Inter alone,
/// and Japanese characters draw as Inter's missing-glyph box.
/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is
/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's
/// missing-glyph box.
fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::FontRef<'static>> {
let mut japanese: Vec<_> = crate::fonts::japanese(craft).collect();
japanese.sort_by_key(|f| !f.is_mincho());
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
cjk.sort_by_key(|f| !f.is_mincho());
std::iter::once(WATERMARK_FONT)
.chain(japanese.into_iter().map(|f| f.bytes))
.chain(cjk.into_iter().map(|f| f.bytes))
.filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok())
.collect()
}

View File

@ -1,9 +1,9 @@
//! Fonts from the optional craft-fonts build input (storytold/craft-fonts).
//!
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
//! manifest (today: the Japanese UI and document fonts); otherwise it is empty and LightCraft uses
//! only its own bundled fonts (Inter). Both the egui UI and the export watermark renderer read it
//! from here. See craftrules `standards/fonts.md`.
//! manifest (today: the Japanese and Simplified-Chinese UI and document fonts); otherwise it is
//! empty and LightCraft uses only its own bundled fonts (Inter). Both the egui UI and the export
//! watermark renderer read it from here. See craftrules `standards/fonts.md`.
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
pub struct CraftFont {
@ -33,6 +33,43 @@ pub fn japanese(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static Cr
fonts.iter().filter(|f| f.covers("Jpan"))
}
/// The craft-fonts faces for any CJK script: what any CJK text needs, regardless of language.
pub fn cjk(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static CraftFont> {
fonts.iter().filter(|f| f.scripts.iter().any(|script| is_cjk_script(script)))
}
/// Whether an ISO 15924 script is written with Han, kana or hangul characters (Unicode's CJK
/// scripts), which Inter has no glyphs for.
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.
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| {
(
// The language's own script first. A face dedicated to the script is preferred over a
// multi-script face (Noto Sans CJK SC claims `Hans,Latn`; a Japanese face claims `Jpan`).
!font.covers(script),
font.scripts.len() != 1,
font.family != "BIZ UDPGothic",
font.style != style,
font.is_mincho(),
)
});
faces
}
/// Whether the font is for any CJK script.
fn covers_cjk(font: &CraftFont) -> bool {
font.scripts.iter().any(|script| is_cjk_script(script))
}
#[cfg(test)]
mod tests {
use super::*;
@ -49,4 +86,21 @@ mod tests {
}
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
}
/// Each language's own faces come first, so shared Han keeps the language's forms, and Latin
/// text still reaches Inter before any CJK fallback.
#[test]
fn a_language_gets_its_own_script_first() {
static FACES: &[CraftFont] = &[
CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] },
CraftFont { family: "BIZ UDMincho", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
];
let first = |script: &str| cjk_fallback(FACES, script, "Regular").first().map(|f| f.family);
assert_eq!(first("Hans"), Some("Noto Sans CJK SC"));
assert_eq!(first("Jpan"), Some("BIZ UDPGothic"));
// Every CJK face is still offered to every language.
assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3);
assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0);
}
}

View File

@ -1,18 +1,100 @@
//! Compile translated format strings so Rust checks both languages' placeholders.
use std::{collections::BTreeMap, env, fs, path::PathBuf};
//! Compile the translated format strings into the `tr_format!` macro.
//!
//! `locales/<code>-formats.json` maps an English format string to its translation for one language.
//! Every English message becomes one `tr_format!` arm that picks the active language and formats
//! that language's translation with `format!` — so Rust checks every catalog's placeholders against
//! the call site's arguments (a wrong or forgotten placeholder is a compile error, and `{:.1}`,
//! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means
//! adding its file (and its `language_table!` entry); this script picks the file up by name.
//!
//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog
//! does not compile, so a message missing from one language would otherwise go unnoticed until it
//! shows English.
use std::{
collections::{BTreeMap, BTreeSet},
env, fs,
path::PathBuf,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=locales/ja-formats.json");
let messages: BTreeMap<String, String> = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?;
let mut source = String::from("macro_rules! tr_format {\n");
for (english, japanese) in messages {
let en = serde_json::to_string(&english)?;
let ja = serde_json::to_string(&japanese)?;
source.push_str(&format!(
"({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n"
));
}
source.push_str("}\npub(crate) use tr_format;\n");
fs::write(PathBuf::from(env::var("OUT_DIR")?).join("ja-formats.rs"), source)?;
println!("cargo:rerun-if-changed=build.rs");
// The directory itself, so a catalog added or removed reruns this script.
println!("cargo:rerun-if-changed=locales");
let out = PathBuf::from(env::var("OUT_DIR")?);
fs::write(out.join("tr-formats.rs"), generate()?)?;
Ok(())
}
/// `ja` → `Ja`, `zh-hans` → `ZhHans`: the `Locale` variant the `language_table!` entry declares.
/// The code must be a lowercase BCP-47-like tag (`^[a-z]+(-[a-z]+)*$`), so the variant is always a
/// valid Rust identifier.
fn variant_for(code: &str) -> Result<String, String> {
let valid = !code.is_empty() && code.split('-').all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_lowercase()));
if !valid {
return Err(format!("locales/{code}-formats.json: the language code must match ^[a-z]+(-[a-z]+)*$"));
}
Ok(code.split('-').map(capitalize).collect())
}
fn capitalize(part: &str) -> String {
let mut chars = part.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn generate() -> Result<String, Box<dyn std::error::Error>> {
let mut catalogs: Vec<(String, String, BTreeMap<String, String>)> = Vec::new();
for entry in fs::read_dir("locales")? {
let path = entry?.path();
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_string();
// `en` is the source language: it has no catalog, its formats are the source themselves.
let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue };
println!("cargo:rerun-if-changed=locales/{name}");
let variant = variant_for(code)?;
let messages: BTreeMap<String, String> =
serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?;
catalogs.push((code.to_string(), variant, messages));
}
catalogs.sort_by(|a, b| a.0.cmp(&b.0));
// Every catalog translates the same messages.
let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect();
let mut problems = Vec::new();
for (code, _, messages) in &catalogs {
for key in &all {
if !messages.contains_key(*key) {
problems.push(format!("locales/{code}-formats.json is missing {key:?}"));
}
}
}
if !problems.is_empty() {
return Err(problems.join("\n").into());
}
let mut source = String::from(concat!(
"/// Translate a format string, checked by `format!` in every language.\n",
"/// `build.rs` writes one arm per message from `locales/*-formats.json`.\n",
"macro_rules! tr_format {\n",
));
for english in all {
let en = serde_json::to_string(english)?;
source.push_str(&format!(" ({en} $(, $($args:tt)*)?) => {{\n match $crate::i18n::language() {{\n"));
for (_, variant, messages) in &catalogs {
if let Some(translated) = messages.get(english) {
let translated = serde_json::to_string(translated)?;
source.push_str(&format!(" $crate::i18n::Locale::{variant} => format!({translated} $(, $($args)*)?),\n"));
}
}
source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n"));
}
source.push_str(concat!(
"}\n",
"\n",
"// `macro_rules!` is textual: the module re-export is what lets call sites say\n",
"// `crate::i18n::tr_format!`.\n",
"pub(crate) use tr_format;\n",
));
Ok(source)
}

View File

@ -0,0 +1,107 @@
{
" · {skipped} skipped (file exists)": " · 已跳过 {skipped} 个(文件已存在)",
" · {} duplicate{} skipped": " · 已跳过 {0} 个重复项{1:.0}",
" · {} failed: {}": " · {0} 个失败:{1}",
" · {} moved": " · 已移动 {0} 个",
" · {} not readable": " · {0} 个无法读取",
", {failed} file{} not readable": ",{failed} 个文件无法读取{0:.0}",
"Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片",
"Add {} Photo{}": "添加 {0} 张照片{1:.0}",
"Add “{}” to Favorites": "将“{0}”添加到收藏夹",
"Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}",
"Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}",
"All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。",
"Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片",
"Automatic ({} MB)": "自动({0} MB)",
"Click {}:1": "单击 {0}:1",
"Create {n} Virtual Copies": "创建 {n} 个虚拟副本",
"Decoder: {reason}": "解码器:{reason}",
"Delete \"{label}\"": "删除 \"{label}\"",
"Delete \"{label}\" (and the mask)": "删除 \"{label}\"(及蒙版)",
"Delete {n} Photos": "删除 {n} 张照片",
"Delete “{current}”": "删除“{current}”",
"Duplicate \"{label}\"": "复制 \"{label}\"",
"Example: {example}": "示例:{example}",
"Exported {n} photo{}": "已导出 {n} 张照片{0:.0}",
"Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片{0:.0}",
"Exported {} point curve preset{}": "已导出 {0} 个点曲线预设{1:.0}",
"Exported {} preset{}": "已导出 {0} 个预设{1:.0}",
"Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张",
"Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选{0:.0}",
"Healed {n} dust spot{}": "已修复 {n} 处尘点{0:.0}",
"Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设{0:.0}",
"Imported {n} preset{}": "已导入 {n} 个预设{0:.0}",
"Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取{0:.0}{1:.0}",
"Imported {} photo{}": "已导入 {0} 张照片{1:.0}",
"Matches: {}": "匹配项:{0}",
"Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%",
"Move {what} to Recently Deleted?": "将 {what} 移到最近删除?",
"Named ({named_n})": "已命名({named_n})",
"Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}",
"Preset: {name}": "预设:{name}",
"Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({0})",
"Preview only — {}": "仅预览 — {0}",
"Profile: {}": "配置文件:{0}",
"Proof Copy ({})": "校样副本({0})",
"Proof Preview · {}": "校样预览 · {0}",
"Quick Develop · {n} photos": "快速编辑 · {n} 张照片",
"Rated {}": "已评级 {0}",
"Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张",
"Redo {}": "重做:{0}",
"Remove “{}” from Favorites": "从收藏夹中移除“{0}”",
"Rename {n} Photos…": "重命名 {n} 张照片…",
"Rename “{name}”": "重命名“{name}”",
"Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}",
"Reset {} Channel": "重置 {0} 通道",
"Saved export preset “{}”": "已保存导出预设“{0}”",
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}",
"Showing {n} of {}": "显示 {0} 张中的 {n} 张",
"Smart album: {}": "智能相册:{0}",
"Undo {}": "撤销 {0}",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {0} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。",
"Version: {}": "版本:{0}",
"none (CPU rendering): {why}": "无(CPU 渲染):{why}",
"the {n} selected photos": "选中的 {n} 张照片",
"the {} photos in view": "视图中的 {0} 张照片",
"these {count} photos": "这 {count} 张照片",
"{count} smart preview{} · {:.1} MB": "{count} 个智能预览{0:.0} · {1:.1} MB",
"{f} folders (and their subfolders)": "{f} 个文件夹(及其子文件夹)",
"{label} Grading": "{label} 颜色分级",
"{label} {d:+} on every selected photo": "每张选中照片的 {label} {d:+}",
"{label}, {n} photos": "{label},{n} 张照片",
"{matching} of {t} photos": "{t} 张照片中的 {matching} 张",
"{matching} photos": "{matching} 张照片",
"{mode} spot {} of {}": "{mode}:第 {0} 个污点,共 {1} 个",
"{msg} ({failed} failed)": "{msg}({failed} 项失败)",
"{name} Label": "{name} 标签",
"{name} Off": "已关闭 {name}",
"{name} On": "已开启 {name}",
"{name} — double-click to reset it": "{name} — 双击可重置",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 项更改已保存在内存中,但尚未写入磁盘:{e}\nLightCraft 会自动重试;现在退出将丢失这些更改。{0:.0}{1:.0}",
"{n} found · {sel} selected": "找到 {n} 张 · 已选 {sel} 张",
"{n} photos": "{n} 张照片",
"{n} photos in {} are hidden by the filters above": "{0} 中的 {n} 张照片被上方的过滤器隐藏",
"{n} photo{}": "{n} 张照片{0:.0}",
"{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新{0:.0}",
"{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新{0:.0}",
"{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}",
"{stage}… {:.0}%": "{stage}… {0:.0}%",
"{what} merge added": "已添加 {what} 合并",
"{what} merge failed: {e}": "{what} 合并失败:{e}",
"{} and higher": "{0} 及以上",
"{} files": "{0} 个文件",
"{} files and {f} folder{}": "{0} 个文件和 {f} 个文件夹{1:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {0} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。",
"{} photos": "{0} 张照片",
"{} photo{} not readable": "{0} 张照片无法读取{1:.0}",
"{} spot(s) on this photo": "此照片上有 {0} 处污点",
"· {dups} already in the library": "· {dups} 张已在图库中",
"· {n} photo{}": "· {n} 张照片{0:.0}",
"… and {more} more": "…以及另外 {more} 项",
"Import cancelled · {} photo{} added": "导入已取消 · 已添加 {0} 张照片{1:.0}",
"Reading photos… {} of {}": "正在读取照片… 第 {0} 张,共 {1} 张",
"Adding photos… {} of {}": "正在添加照片… 第 {0} 张,共 {1} 张",
" ({} in Recently Deleted)": "(其中 {0} 张在“最近删除”中)",
"Already in the library: {} photo{} selected in All Photos": "已在图库中:已在“所有照片”中选择 {0} 张照片{1:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入"
}

File diff suppressed because it is too large Load Diff

View File

@ -55,7 +55,7 @@ fn wrap(r: Result<Value, String>) -> Outcome {
pub fn all_commands(app: &LightcraftApp) -> Value {
let mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label, sc, menu) in crate::menus::UI_COMMANDS {
for (id, label, sc, menu) in crate::menus::ui_commands() {
v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true}));
}
Value::Array(v)

View File

@ -1,123 +1,418 @@
//! Presentation-only localization. Command ids, user names and catalog data remain unchanged.
use std::{cell::Cell, collections::BTreeMap, sync::OnceLock};
//!
//! A language is one entry in [`language_table!`] below: its BCP-47 code, the endonym shown in the
//! Language menus, the ISO 15924 script its text needs (which picks the CJK font fallback), and its
//! message catalog (`locales/<code>.json`, embedded at build time). Adding a language is that entry
//! plus its catalogs — every menu and settings surface is built from the table, so nothing else in
//! the UI changes.
use serde::{Deserialize, Serialize};
use std::{cell::RefCell, collections::BTreeMap, sync::OnceLock};
include!(concat!(env!("OUT_DIR"), "/ja-formats.rs"));
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Language {
#[default]
#[serde(rename = "en")]
En,
#[serde(rename = "ja")]
Ja,
/// The language table.
///
/// The source language comes first and alone: it is the text the UI is authored in, which every
/// other language translates. `name` is written in the language itself (an endonym, so a user who
/// cannot read the current UI language can still find their own).
macro_rules! language_table {
($source:ident, $source_code:literal; $( $variant:ident, $code:literal, $name:literal, $script:literal, $catalog:expr );* $(;)?) => {
/// A UI language.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub enum Locale {
/// The language the UI is written in.
#[default]
$source,
$($variant,)*
}
impl Locale {
/// Every language, in menu order (the source language first).
pub const ALL: &'static [Self] = &[Self::$source, $(Self::$variant,)*];
pub const fn code(self) -> &'static str {
match self { Self::$source => $source_code, $(Self::$variant => $code,)* }
}
/// The language's own name, as shown in the Language menus.
pub const fn name(self) -> &'static str {
match self { Self::$source => "English", $(Self::$variant => $name,)* }
}
/// The ISO 15924 script this language is written in (`"Latn"`, `"Jpan"`, `"Hans"`…):
/// the craft-fonts faces for it are the ones this language needs.
pub const fn script(self) -> &'static str {
match self { Self::$source => "Latn", $(Self::$variant => $script,)* }
}
/// The language's embedded catalog, parsed once. The source language has none: its
/// lookups return the source text itself.
fn catalog(self) -> &'static BTreeMap<String, String> {
static CATALOGS: OnceLock<BTreeMap<Locale, BTreeMap<String, String>>> = OnceLock::new();
static EMPTY: OnceLock<BTreeMap<String, String>> = OnceLock::new();
// A malformed catalog logs and comes back empty: a broken translation degrades to
// English text, it never takes the app down.
let parse = |catalog: Option<&'static str>| match catalog {
Some(json) => match serde_json::from_str(json) {
Ok(messages) => messages,
Err(error) => {
log::error!("Invalid message catalog for {}: {error}", self.code());
BTreeMap::new()
}
},
None => BTreeMap::new(),
};
CATALOGS
.get_or_init(|| {
Locale::ALL
.iter()
.map(|language| {
let catalog = match *language { $(Self::$variant => Some($catalog),)* _ => None };
(*language, parse(catalog))
})
.collect()
})
.get(&self)
.unwrap_or_else(|| EMPTY.get_or_init(BTreeMap::new))
}
pub fn parse(code: &str) -> Option<Self> {
match code { $source_code => Some(Self::$source), $($code => Some(Self::$variant),)* _ => None }
}
/// Lenient lookup: `zh`, `zh-CN`, `zh_Hans`, `en-US`, `ja_JP.UTF-8`… all reach a
/// shipped language. A region or script the table does not ship falls back to the
/// language itself, so a user is never left with no language at all.
pub fn parse_tag(tag: &str) -> Option<Self> {
let tag = tag.split(['.', '@']).next().unwrap_or(tag).replace('_', "-");
let lower = tag.to_ascii_lowercase();
if let Some(exact) = Self::ALL.iter().find(|language| language.code().eq_ignore_ascii_case(&lower)) {
return Some(*exact);
}
let mut parts = lower.split('-');
let language = parts.next()?;
// `zh-Hans-CN`, `zh-CN` and `zh` all name the same base language.
let script = parts.next().and_then(|part| match part {
"hans" | "cn" | "sg" => Some("Hans"),
"hant" | "tw" | "hk" | "mo" => Some("Hant"),
_ => None,
});
Self::ALL
.iter()
.find(|candidate| {
let base = candidate.code().split('-').next().unwrap_or_default();
base == language && script.is_none_or(|script| candidate.script() == script)
})
.copied()
}
pub fn tr(self, source: &str) -> &str {
tr_in(self, source)
}
}
};
}
impl Language {
pub const ALL: [Self; 2] = [Self::En, Self::Ja];
pub fn name(self) -> &'static str {
match self {
Self::En => "English",
Self::Ja => "日本語",
}
// The source language, then one entry per translation: code, endonym, ISO 15924 script, embedded
// catalog (`locales/<code>.json`).
language_table! {
En, "en";
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
}
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
// language can be renamed in code without invalidating anyone's settings.
impl Serialize for Locale {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.code())
}
pub fn parse(code: &str) -> Option<Self> {
match code {
"en" => Some(Self::En),
"ja" => Some(Self::Ja),
_ => None,
}
}
pub fn tr(self, source: &str) -> &str {
if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
}
impl<'de> Deserialize<'de> for Locale {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let code = String::deserialize(deserializer)?;
Locale::parse(&code).or_else(|| Locale::parse_tag(&code)).ok_or_else(|| serde::de::Error::custom(format!("unknown language {code:?}")))
}
}
thread_local! {
static LANGUAGE: Cell<Language> = const { Cell::new(Language::En) };
static LOCALE: std::cell::Cell<Locale> = const { std::cell::Cell::new(Locale::En) };
}
pub fn default_language() -> Language {
if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En }
/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs.
pub fn default_language() -> Locale {
std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En)
}
pub fn set_language(language: Language) {
LANGUAGE.with(|value| value.set(language));
pub fn set_language(language: Locale) {
LOCALE.with(|value| value.set(language));
}
pub fn is_japanese() -> bool {
LANGUAGE.with(|value| value.get() == Language::Ja)
}
fn japanese() -> &'static BTreeMap<String, String> {
static MESSAGES: OnceLock<BTreeMap<String, String>> = OnceLock::new();
MESSAGES.get_or_init(|| {
serde_json::from_str(include_str!("../locales/ja.json")).unwrap_or_else(|error| {
log::error!("Invalid Japanese message catalog: {error}");
BTreeMap::new()
})
})
pub fn language() -> Locale {
LOCALE.with(std::cell::Cell::get)
}
/// Translate a built-in display label, preserving unknown labels verbatim.
/// Never call this on editable user text, filenames or command identifiers.
pub fn tr(source: &str) -> &str {
if is_japanese() { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
tr_in(language(), source)
}
/// Catalog values are `'static` (they live in the embedded file), so a translation can be handed
/// out for as long as the `'static` key it was looked up by.
fn tr_in(language: Locale, source: &str) -> &str {
if language.catalog().is_empty() {
return source;
}
thread_local! {
static VERBATIM: RefCell<BTreeMap<Locale, BTreeMap<String, &'static str>>> = const { RefCell::new(BTreeMap::new()) };
}
VERBATIM.with_borrow_mut(|cache| {
let verbatim = cache.entry(language).or_insert_with(|| catalog_verbatim(language));
verbatim.get(source).copied().unwrap_or(source)
})
}
/// One language's catalog keyed for lookup, with values that outlive the catalog's own borrow.
fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> {
language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect()
}
// `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from
// `locales/*-formats.json`.
include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_is_valid_and_contains_core_workflows() {
let messages: BTreeMap<String, String> = serde_json::from_str(include_str!("../locales/ja.json")).unwrap();
for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] {
assert!(messages.get(key).is_some_and(|value| !value.is_empty() && value != key), "{key}");
fn every_language_has_a_catalog_covering_the_source() {
assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog");
for language in Locale::ALL {
assert!(!language.name().is_empty(), "{language:?} has a name");
assert!(!language.script().is_empty(), "{language:?} has a script");
assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code");
if *language == Locale::En {
continue;
}
let messages = language.catalog();
assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code());
for (key, value) in messages {
assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}");
}
}
}
/// The catalogs of the translations agree: same keys, same format placeholders. A source string
/// added to one language but forgotten in another fails here.
#[test]
fn language_switches_and_unknown_text_survives() {
set_language(Language::Ja);
assert_eq!(tr("Exposure"), "露出");
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
assert_eq!(tr("develop.set"), "develop.set");
assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー");
set_language(Language::En);
assert_eq!(tr("Exposure"), "Exposure");
fn catalogs_agree_on_keys_and_placeholders() {
let fields = |text: &str| {
let mut out: Vec<String> = Vec::new();
let mut rest = text;
while let Some(start) = rest.find('{') {
let Some(end) = rest[start..].find('}') else { break };
out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string());
rest = &rest[start + end + 1..];
}
out.sort();
out
};
let translated: Vec<Locale> = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect();
let Some((first, rest)) = translated.split_first() else { return };
for language in rest {
let (a, b) = (first.catalog(), language.catalog());
for (key, value) in a {
let Some(other) = b.get(key) else {
panic!("{language:?} is missing a translation for {key:?}");
};
assert_eq!(fields(key), fields(value), "{first:?}: {key:?} -> {value:?}");
assert_eq!(fields(key), fields(other), "{language:?}: {key:?} -> {other:?}");
}
for key in b.keys() {
assert!(a.contains_key(key), "{language:?} translates {key:?}, which {first:?} does not");
}
}
}
/// Locale tags are matched leniently, so a system locale reaches a shipped language.
#[test]
fn locale_tags_normalize() {
assert_eq!(Locale::parse_tag("en"), Some(Locale::En));
assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En));
assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja));
assert_eq!(Locale::parse_tag("de"), None);
}
#[test]
fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() {
set_language(Language::Ja);
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12枚");
fn switching_language_translates_and_restores() {
set_language(Locale::ZhHans);
assert_eq!(tr("Exposure"), "曝光");
assert_eq!(tr("Settings"), "设置");
// Untranslated and data-like text passes through untouched.
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
assert_eq!(tr("develop.set"), "develop.set");
assert_eq!(tr("A string no catalog has"), "A string no catalog has");
set_language(Locale::Ja);
assert_eq!(tr("Exposure"), "露出");
set_language(Locale::En);
assert_eq!(tr("Exposure"), "Exposure");
}
/// Format strings carry their values in every language, and a language without a translation
/// falls back to the English format rather than dropping the values.
#[test]
fn formats_render_in_every_language() {
// Every language's format string is checked by `format!` against the call site's values.
set_language(Locale::En);
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos");
set_language(Locale::ZhHans);
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片");
set_language(Locale::Ja);
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました");
set_language(Language::En);
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 photos");
// Format specs survive translation: precision, sign, and values a language reorders.
for language in Locale::ALL {
set_language(*language);
let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3);
assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}");
let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning");
assert!(merging.contains("42%"), "{language:?}: {merging}");
let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5);
assert!(delta.contains("+5"), "{language:?}: {delta}");
let reading = tr_format!("Reading photos… {} of {}", 3, 10);
let (three, ten) = (reading.find('3'), reading.find("10"));
assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}");
}
set_language(Locale::En);
assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10");
}
/// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation
/// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK
/// catalogs are skipped (the same degradation the Japanese UI has always had).
#[test]
fn catalog_characters_are_paintable() {
let ctx = egui::Context::default();
ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS));
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
for language in Locale::ALL {
let messages = language.catalog();
if messages.is_empty() {
continue;
}
// The faces this language's script needs; a language the craft-fonts input does not
// cover (a translation added ahead of its font) is reported, not failed.
let mut faces: Vec<&str> =
lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.covers(language.script())).map(|font| font.family).collect();
faces.dedup();
if faces.is_empty() {
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
continue;
}
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family.clone());
let mut missing: Vec<char> = messages
.values()
.flat_map(|message| message.chars())
.filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch))
.collect();
missing.sort_unstable();
missing.dedup();
assert!(
missing.is_empty(),
"{} in {family:?} (faces: {faces:?}) lack glyphs: {}",
language.code(),
missing.iter().collect::<String>()
);
}
});
}
}
/// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a
/// translation must not carry a symbol no installed font has (egui's defaults cover the rest).
#[test]
fn interface_symbols_are_paintable() {
let ctx = egui::Context::default();
ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS));
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
// ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07).
ctx.fonts_mut(|fonts| {
for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] {
let font = egui::FontId::new(13.0, egui::FontFamily::Proportional);
assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font");
}
});
}
/// The language menu covers every language, and a language's own command selects it.
#[test]
fn language_commands_cover_every_language() {
let commands =
[("app.language.english", Locale::En), ("app.language.simplifiedChinese", Locale::ZhHans), ("app.language.japanese", Locale::Ja)];
// One command per language, and every command reachable from the menu table.
assert_eq!(commands.len(), Locale::ALL.len());
for (id, language) in commands {
assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}");
assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu");
}
assert_eq!(crate::menus::language_from_command("app.language.klingon"), None);
assert_eq!(crate::menus::language_from_command("view.detail"), None);
}
#[test]
fn preferences_round_trip_and_old_settings_remain_readable() {
let old: crate::state::UiState = serde_json::from_str("{}").unwrap();
assert_eq!(old.language, Language::En);
let settings = crate::state::UiState { language: Language::Ja, ..old };
assert_eq!(old.language, Locale::En);
// Settings written before the language list grew still load.
let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap();
assert_eq!(legacy.language, Locale::Ja);
let settings = crate::state::UiState { language: Locale::ZhHans, ..old };
let saved = serde_json::to_string(&settings).unwrap();
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Language::Ja);
assert_eq!(restored.language, Locale::ZhHans);
}
#[test]
fn japanese_is_painted_and_both_font_weights_cover_the_catalog() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.language = Language::Ja;
app.ui.left_panel = true;
let mut text = String::new();
fn catalogs_contain_core_workflows() {
for language in Locale::ALL.iter().filter(|language| **language != Locale::En) {
for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] {
let value = language.tr(key);
assert!(!value.is_empty() && value != key, "{language:?}: {key}");
}
}
}
/// User-named menu items (presets, albums, label sets) are never translated; built-in labels are.
#[test]
fn menu_labels_translate_but_user_names_survive() {
for language in Locale::ALL {
set_language(*language);
assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color"));
}
set_language(Locale::ZhHans);
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "颜色");
set_language(Locale::Ja);
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー");
set_language(Locale::En);
}
/// The text the whole window paints over a few frames in `language`.
fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String {
fn collect(shape: &egui::epaint::Shape, text: &mut String) {
match shape {
egui::epaint::Shape::Text(shape) => {
@ -128,41 +423,49 @@ mod tests {
_ => {}
}
}
app.ui.language = language;
let mut text = String::new();
for frame in 0..4 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
// This assertion inspects shapes without a renderer; discard texture uploads explicitly.
// This inspects shapes without a renderer; discard texture uploads explicitly.
out.textures_delta.clear();
text.clear();
for shape in out.shapes {
collect(&shape.shape, &mut text);
}
}
assert!(text.contains("マイフォト"), "{text}");
assert!(text.contains("すべての写真"), "{text}");
if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() {
eprintln!("skipped glyph coverage: built without CRAFT_FONTS_DIR, so there is no Japanese UI font");
} else {
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for message in japanese().values() {
for ch in message.chars().filter(|ch| !ch.is_whitespace()) {
assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}");
}
}
}
});
}
// Locale affects presentation only: command ids remain the same.
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
let japanese_ids = ids(&app);
set_language(Language::En);
assert_eq!(japanese_ids, ids(&app));
text
}
/// Every language is painted by the real window, switching at runtime reinstalls the fonts
/// (their CJK fallback order follows the language), and command ids never change with it.
#[test]
fn every_language_is_painted_and_menu_ids_stay_the_same() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.left_panel = true;
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
let english_ids = ids(&app);
for language in Locale::ALL {
let text = painted_text(&ctx, &mut app, *language);
for label in ["My Photos", "All Photos"] {
assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label));
}
assert_eq!(app.font_language, *language, "the fonts follow the language");
assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent");
}
let text = painted_text(&ctx, &mut app, Locale::Ja);
assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}");
let text = painted_text(&ctx, &mut app, Locale::ZhHans);
assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}");
set_language(Locale::En);
}
/// The UI families, after one frame so the font definitions are loaded.
fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context {
let ctx = egui::Context::default();
@ -172,31 +475,36 @@ mod tests {
ctx
}
fn ui_families() -> [egui::FontFamily; 3] {
[egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace]
}
/// Built with craft-fonts, Japanese renders with real glyphs (no tofu) in every UI family.
/// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI
/// family, Monospace included.
#[test]
fn craft_fonts_render_japanese_in_the_ui() {
if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() {
eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese UI font");
return;
}
let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS);
ctx.fonts_mut(|fonts| {
for family in ui_families() {
let font = egui::FontId::new(13.0, family);
for ch in "日本語の文字".chars() {
assert!(fonts.has_glyph(&font, ch), "{ch} in {font:?}");
}
let galley = fonts.layout_no_wrap("日本語の文字".into(), font.clone(), egui::Color32::WHITE);
assert!(galley.size().x > 13.0 * 5.0, "{font:?}: six full-width glyphs, {:?}", galley.size());
fn craft_fonts_render_cjk_in_the_ui() {
let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字")];
for (language, sample) in samples {
if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.covers(language.script())) {
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
continue;
}
});
set_language(language);
let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS);
ctx.fonts_mut(|fonts| {
for family in
[egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace]
{
let font = egui::FontId::new(13.0, family);
for ch in sample.chars() {
assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}");
}
let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE);
let wide = 13.0 * (sample.chars().count() as f32 - 1.0);
assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size());
}
});
}
set_language(Locale::En);
}
/// Built without craft-fonts, the UI (in Japanese, too) still installs its fonts and runs;
/// Built without craft-fonts, the UI (in every language) still installs its fonts and runs;
/// Latin text keeps Inter.
#[test]
fn the_ui_works_without_craft_fonts() {
@ -210,15 +518,17 @@ mod tests {
}
});
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.language = Language::Ja;
for frame in 0..3 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
for language in Locale::ALL {
app.ui.language = *language;
for frame in 0..3 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
}
set_language(Language::En);
set_language(Locale::En);
}
}

View File

@ -1,7 +1,7 @@
//! LightCraft's egui frontend: a Lightroom-style UI over `lightcraft-engine`.
//!
//! The UI is thin: every action goes through [`LightcraftApp::run`], which handles UI commands
//! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine.
//! (views, panels, zoom — see [`menus::ui_commands`]) and forwards everything else to the engine.
//! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@ -157,6 +157,9 @@ pub struct LightcraftApp {
/// Clear `synthetic_mods` on the next frame.
synthetic_mods_release: bool,
styled: bool,
/// The language the installed fonts were built for: the CJK fallback order follows the UI
/// language's script, so switching language reinstalls them.
font_language: i18n::Locale,
fonts_ready: bool,
last_time: f64,
/// Rect of the photo canvas and the displayed image (screen points) from the last frame.
@ -223,6 +226,7 @@ impl LightcraftApp {
synthetic_mods: egui::Modifiers::NONE,
synthetic_mods_release: false,
styled: false,
font_language: i18n::Locale::En,
fonts_ready: false,
last_time: 0.0,
canvas_rect: None,
@ -502,6 +506,12 @@ impl LightcraftApp {
let repaint = ctx.clone();
self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint()));
self.styled = true;
self.font_language = self.ui.language;
} else if self.font_language != self.ui.language {
// Shared Han characters take the active language's forms (Japanese faces for 日本語,
// the Simplified Chinese face for 简体中文): rebuild the fallback order.
theme::install_fonts(ctx);
self.font_language = self.ui.language;
} else {
self.fonts_ready = true;
}

View File

@ -1,5 +1,5 @@
//! The menu bar model: File, Edit, View, Photo, Window, Help, generated from the command registry
//! (engine commands with menu paths + [`crate::menus::UI_COMMANDS`]) with live labels, shortcuts,
//! (engine commands with menu paths + [`crate::menus::ui_commands`]) with live labels, shortcuts,
//! enabled and checked state.
//!
//! One model drives every menu surface: the native macOS menu bar (built by the desktop host), the
@ -284,8 +284,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
let u = &app.ui;
let panel = |p: RightPanel| Some(u.right == p);
match id {
"app.language.english" => Some(u.language == crate::i18n::Language::En),
"app.language.japanese" => Some(u.language == crate::i18n::Language::Ja),
// Every language's command is checked when it is the active one.
_ if crate::menus::language_from_command(id).is_some() => Some(crate::menus::language_from_command(id) == Some(u.language)),
"develop.autoSync" => Some(app.session.auto_sync),
"view.photoCounts" => Some(u.show_counts),
"view.secondWindow" => Some(u.second_window),

View File

@ -10,9 +10,32 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom};
/// (id, label, shortcut, menu path)
pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str);
/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language
/// added to the table shows up in the menu (and in the control channel's command list) by itself.
pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"),
("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"),
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
];
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
/// this file, so an id listed in `docs/parity.md` is checked wherever it is declared.
pub fn ui_commands() -> impl Iterator<Item = &'static UiCommand> {
LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS)
}
/// The language a Language-menu command selects, if the id is one. The engine and the UI both go
/// through here, so the menu, the settings row and the control channel agree on the mapping.
pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> {
match id {
"app.language.english" => Some(crate::i18n::Locale::En),
"app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans),
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
_ => None,
}
}
pub const UI_COMMANDS: &[UiCommand] = &[
("app.language.english", "English", None, "Edit>Language"),
("app.language.japanese", "日本語", None, "Edit>Language"),
("view.photoGrid", "Photo Grid", None, "View"),
("view.squareGrid", "Square Grid", None, "View"),
// G: Photo Grid ↔ Square Grid (from other views: the photo grid)
@ -208,8 +231,8 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if matches!(id, "app.language.english" | "app.language.japanese") {
app.ui.language = if id == "app.language.japanese" { crate::i18n::Language::Ja } else { crate::i18n::Language::En };
if let Some(language) = language_from_command(id) {
app.ui.language = language;
return Some(Ok(json!(app.ui.language)));
}
let ctx = egui::Context::default();
@ -1261,8 +1284,7 @@ pub struct MenuEntry {
/// The flattened menu model (UI commands + engine commands with menu paths).
pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
let mut v: Vec<MenuEntry> = UI_COMMANDS
.iter()
let mut v: Vec<MenuEntry> = ui_commands()
.filter(|c| !c.3.is_empty())
.map(|(id, label, sc, m)| MenuEntry {
id: id.to_string(),

View File

@ -733,7 +733,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Shortcuts => {
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
for (id, label, sc, _) in crate::menus::UI_COMMANDS {
for (id, label, sc, _) in crate::menus::ui_commands() {
if let Some(sc) = sc {
ui.label(crate::i18n::tr(label));
ui.label(*sc);
@ -750,8 +750,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
for (sc, id, _) in crate::shortcuts::ALIASES {
let label = crate::menus::UI_COMMANDS
.iter()
let label = crate::menus::ui_commands()
.find(|c| c.0 == *id)
.map(|c| c.1)
.or_else(|| lightcraft_engine::find_command(id).map(|c| c.label))

View File

@ -91,7 +91,8 @@ fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: &[(V, &str
fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
row(ui, t, crate::i18n::tr("Language"), |ui| {
choices(ui, "settingsLanguage", &[(crate::i18n::Language::Ja, "日本語"), (crate::i18n::Language::En, "English")], &mut app.ui.language);
let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect();
choices(ui, "settingsLanguage", &languages, &mut app.ui.language);
crate::i18n::set_language(app.ui.language);
});
heading(ui, t, crate::i18n::tr("Library"));

View File

@ -89,7 +89,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new();
ctx.input(|i| {
let mut ui_keys = Vec::new();
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
for (id, _, sc, _) in crate::menus::ui_commands() {
if let Some((m, k)) = sc.and_then(parse) {
ui_keys.push((m, k));
if !native(sc.unwrap_or_default()) && matches(i, m, k) {
@ -286,7 +286,7 @@ mod tests {
assert_eq!(parse("Right").unwrap().1, Key::ArrowRight);
assert_eq!(parse("\\").unwrap().1, Key::Backslash);
// every declared shortcut parses
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
for (id, _, sc, _) in crate::menus::ui_commands() {
if let Some(sc) = sc {
assert!(parse(sc).is_some(), "{id}: {sc}");
}
@ -300,7 +300,7 @@ mod tests {
#[test]
fn aliases_parse_and_target_existing_commands() {
let ui: Vec<&str> = crate::menus::UI_COMMANDS.iter().map(|c| c.0).collect();
let ui: Vec<&str> = crate::menus::ui_commands().map(|c| c.0).collect();
for (sc, id, params) in ALIASES {
assert!(parse(sc).is_some(), "{id}: {sc}");
assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}");
@ -315,7 +315,7 @@ mod tests {
#[test]
fn no_conflicting_bindings() {
let mut ui: Vec<((Modifiers, Key), String)> = Vec::new();
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
for (id, _, sc, _) in crate::menus::ui_commands() {
if let Some(k) = sc.and_then(parse) {
ui.push((k, id.to_string()));
}

View File

@ -203,7 +203,7 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0;
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
#[serde(default = "crate::i18n::default_language")]
pub language: crate::i18n::Language,
pub language: crate::i18n::Locale,
/// The Build Previews run last announced (its identity, finished?).
#[serde(skip)]
pub preview_build_seen: Option<(usize, bool)>,

View File

@ -110,31 +110,35 @@ pub fn install_fonts(ctx: &egui::Context) {
ctx.set_fonts(font_definitions(lightcraft_engine::CRAFT_FONTS));
}
/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts Japanese faces as
/// the last fallback of every family (BIZ UDPGothic first; Bold first for the semibold family).
/// Without craft-fonts (`craft` empty) Japanese text has no glyphs and shows as boxes.
/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts CJK faces as the last
/// fallback of every family — the active language's own script first, so shared Han characters keep
/// that language's forms. Without craft-fonts (`craft` empty) CJK text has no glyphs and shows as
/// boxes.
pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontDefinitions {
let mut fonts = FontDefinitions::default();
fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"))));
let japanese: Vec<_> = lightcraft_engine::fonts::japanese(craft).collect();
for f in &japanese {
fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes)));
}
// Craft-fonts faces in preference order for a family drawn in `style`.
let fallback = |style: &str| {
let mut faces = japanese.clone();
faces.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != style, f.family.contains("Mincho")));
faces.into_iter().map(craft_font_name).collect::<Vec<_>>()
lightcraft_engine::fonts::cjk_fallback(craft, crate::i18n::language().script(), style)
.into_iter()
.map(craft_font_name)
.collect::<Vec<String>>()
};
let (regular, bold) = (fallback("Regular"), fallback("Bold"));
for name in regular.iter().chain(bold.iter()) {
if let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) {
fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes)));
}
}
let defaults: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let mut prop = vec!["Inter".to_string()];
prop.extend(defaults.iter().cloned());
prop.extend(fallback("Regular"));
prop.extend(regular);
fonts.families.insert(FontFamily::Proportional, prop);
let mut semi = vec!["Inter-SemiBold".to_string()];
semi.extend(defaults);
semi.extend(fallback("Bold"));
semi.extend(bold);
fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi);
fonts.families.entry(FontFamily::Monospace).or_default().extend(fallback("Regular"));
fonts

View File

@ -1,9 +1,9 @@
# LightCraft 日本語表示
LightCraft は日本語と英語(English)の表示に対応しています。
LightCraft は日本語・簡体字中国語・英語(English)の表示に対応しています。
言語設定は `ui.json` の `language` に保存します。
- 「編集 → 言語」または「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。
- 「編集 → 言語」または「設定 → 一般 → 言語」で切り替えます。選択は次回起動にも引き継ぎます。
- メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。
- 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを使います。日本語フォントはこのリポジトリではなく
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) にあり、任意のビルド入力
@ -11,12 +11,16 @@ LightCraft は日本語と英語(English)の表示に対応しています
指定しない場合も動作しますが、日本語の文字は表示されません。
- 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。
- 翻訳されていない技術的なエラー、追加情報、リリースノートは英語で表示します。
- 中国語表示は [`localization-zh-hans.md`](localization-zh-hans.md) を参照してください。
- 別の言語の追加方法は [`localization.md`](localization.md) を参照してください。
## 翻訳の保守
静的な表示文言は `crates/ui-egui/locales/ja.json`、可変値を含む表示文言は
`crates/ui-egui/locales/ja-formats.json` にあります。英文をキーにして翻訳を管理します。
可変文言の両言語はビルド時にRustのフォーマット検査を受けます。
可変文言の訳文はすべてビルド時にRustの `format!` 検査を受けます。値は名前(`{n}`)または
位置(`{0}`、`{1}`)で参照し、`{:.1}` などの書式指定は英文と同じものを使います。
英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。
`LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。

View File

@ -0,0 +1,36 @@
# LightCraft 简体中文界面
LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 `ui.json` 的 `language` 字段中。
- 通过「编辑 → 语言」或「设置 → 一般 → 语言」切换,选择会保留到下次启动。
- 已翻译的范围:菜单、照片编辑、蒙版、裁剪、设置、导入、导出,以及主要的进度提示。
- 正文字重与半粗体都使用 SIL OFL 的 **Noto Sans CJK SC**(简体中文)与 BIZ UDPGothic(日语)。
这些字体不在本仓库,而在 [storytold/craft-fonts](https://github.com/storytold/craft-fonts);
使用可选的构建输入 `CRAFT_FONTS_DIR=../craft-fonts` 时(所有正式发布版)会内嵌进来。
不指定该输入时程序照常构建运行,但中文与日文文字没有字形,会显示为方框。
- 翻译只发生在显示层:命令 ID、照片文件名、用户输入的元数据都不做改动。
- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。
- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
(单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。
原生桌面版不受影响。
- 日期分组标题(如 `Sunday, 20 September 2026`)沿用英文格式,尚未本地化。
## 翻译的维护
静态界面文字在 `crates/ui-egui/locales/zh-hans.json`,含运行时数值的文字在
`crates/ui-egui/locales/zh-hans-formats.json`。两者都以英文原文为键。
含数值的文字遵循以下约定:
- 每个译文都是普通的 Rust 格式字符串,构建时由 `format!` 对照调用点的参数检查(占位符写错会编译失败)。
- **按名字**:`{n}`、`{label}`、`{total}` 等与英文原文中的名字一致。
- **按位置**:`{0}`、`{1}` … 对应调用点无名参数的先后顺序(从 0 开始),用于调整语序,
例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{1}”{0:.0}`。
- 保留英文原文的格式说明(`{:.1}`、`{d:+}`、`{:.0}%`)。
- `format!` 不允许未使用的参数:英文用来表示单复数的那个参数(`"s"` / `""`)在中文里写成
`{:.0}`(精度为 0 的字符串不输出任何内容)。
`LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。
翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由
`cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。

80
docs/localization.md Normal file
View File

@ -0,0 +1,80 @@
# Localization
The interface ships in the language it is written in (English) plus every language in the table in
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`) and Japanese
(`ja`). This file is the reference for **adding or maintaining a language**; the per-language notes
are in [`localization-zh-hans.md`](localization-zh-hans.md) and [`localization-ja.md`](localization-ja.md).
## Adding a language
1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to
`locales/<code>.json` and `locales/<code>-formats.json`, then translate the values. Keys are the
English source strings and must stay byte-for-byte identical — `cargo test -p lightcraft-ui-egui
i18n::tests` fails on a missing, extra or empty entry.
2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`:
```rust
language_table! {
En, "en";
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
}
```
The fields are the BCP-47 code (also the settings-file value), the **endonym** shown in the
Language menu, the ISO 15924 script (`Latn`, `Jpan`, `Hans`, `Hant`, `Kore`…), and the embedded
catalog. `build.rs` picks up `<code>-formats.json` by name, so the macro and the format lookup
follow automatically — nothing else in the UI needs to change. The menus, the settings row, the
keyboard-shortcut sheet and the control channel all read the table.
3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a
language whose script has no face in the build shows boxes (the test says so instead of failing).
Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a
missing face is added there, not worked around in this repository.
4. Run `cargo xtask ci` (it includes `fmt`, `clippy`, the tests, `parity` and the wasm check).
## How a message is found
`tr("Exposure")` looks the English label up in the active language's catalog and returns the source
text unchanged when there is no entry; `tr_format!` handles text with runtime values. Both are
presentation-only: command ids, file names and user-entered metadata are never translated.
`tr_format!` is generated by `crates/ui-egui/build.rs`: one arm per English message, which formats
the active language's translation with `format!` (English is the fallback arm). Every catalog's
format string is therefore checked by Rust against the call site's own arguments:
```rust
tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album, n = n)
```
- A translation is an ordinary Rust format string over the same arguments. Refer to named values by
name (`{n}`) and to positional ones by index when the word order changes (`{0}`, `{1}`):
`"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`).
- `format!` rejects an unused argument, so a value a language does not need — the English plural
suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing).
- Every formats catalog carries the same messages (`build.rs` fails the build otherwise), and a
`tr_format!` call whose message is in no catalog does not compile: add it to every
`*-formats.json` file.
## Testing and looking at it
```sh
cargo test -p lightcraft-ui-egui i18n::tests # catalogs, keys, placeholders, commands
CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage
```
The glyph tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then).
To look at a language, render it headless:
```sh
LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot --demo --script tour.jsonl -o out.png --size 1600x1000
```
or, with the app running (`--control 7980`), run the language command
(`engine.execute {"command": "app.language.simplifiedChinese"}`) and take a screenshot.
## Language codes and settings
The settings file stores the BCP-47 code, never the Rust variant name, so a language can be renamed
in code without invalidating anyone's saved preference. `LIGHTCRAFT_LANGUAGE` accepts what a system
locale looks like (`zh_Hans`, `zh-CN`, `en_US`, `ja_JP.UTF-8`) and falls back to the base language
when a region has no dedicated entry.

View File

@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 15 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
| X. Cross-cutting behaviours (BEHAV) | 16 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) |
| **Total** | 387 | 33 | 87 | 37 | 194/200 (97%) | 139/149 (93%) |
| **Total** | 388 | 33 | 87 | 37 | 194/200 (97%) | 139/149 (93%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.6%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 40.5% of 158.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.6%** of 508 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 40.9% of 159.
<!-- /parity:summary -->
## Top gaps
@ -503,7 +503,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
| 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-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization-ja.md` | Japanese/English UI with persisted language preference and Japanese regular/bold fonts; menus, edit, crop/masks, settings, import/export and primary progress messages. Technical errors/release notes and other locales remain untranslated. |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.japanese` | 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-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese and Japanese 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 is a table entry plus two catalogs. Technical errors/release notes and date grouping headers remain English, and the web build embeds only the Japanese face. |
| 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 | ⬜ | | |

View File

@ -137,12 +137,17 @@ pub fn parse(md: &str) -> Doc {
doc
}
/// Ids in `UI_COMMANDS` (`("view.detail", "Detail", Some("D"), "View"),` lines).
/// Ids in the menu tables (`("view.detail", "Detail", Some("D"), "View"),` lines): the language
/// commands live in their own table, which the Language menu builds from the i18n language list.
pub fn ui_command_ids(menus_rs: &str) -> Vec<String> {
let Some(start) = menus_rs.find("pub const UI_COMMANDS") else { return Vec::new() };
let body = &menus_rs[start..];
let body = &body[..body.find("];").unwrap_or(body.len())];
body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())).collect()
let mut ids = Vec::new();
for table in ["pub const UI_COMMANDS", "pub const LANGUAGE_COMMANDS"] {
let Some(start) = menus_rs.find(table) else { continue };
let body = &menus_rs[start..];
let body = &body[..body.find("];").unwrap_or(body.len())];
ids.extend(body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())));
}
ids
}
fn known(set: &BTreeSet<String>, id: &str) -> bool {
@ -437,5 +442,19 @@ old
fn reads_ui_command_ids() {
let src = "pub const UI_COMMANDS: &[UiCommand] = &[\n (\"view.detail\", \"Detail\", Some(\"D\"), \"View\"),\n (\"app.about\", \"About\", None, \"\"),\n];\nfn x() { (\"not.this\", 1); }";
assert_eq!(ui_command_ids(src), ["view.detail", "app.about"]);
let languages =
"pub const LANGUAGE_COMMANDS: &[UiCommand] = &[\n (\"app.language.english\", Locale::En.name(), None, \"Edit>Language\"),\n];\n";
assert_eq!(ui_command_ids(&format!("{languages}{src}")), ["view.detail", "app.about", "app.language.english"]);
}
/// The real menu tables: both are found, so every language command is a known id.
#[test]
fn reads_the_language_commands_from_menus_rs() {
let menus = include_str!("../../crates/ui-egui/src/menus.rs");
let ids = ui_command_ids(menus);
for id in ["view.detail", "app.language.english", "app.language.japanese", "app.language.simplifiedChinese"] {
assert!(ids.iter().any(|known| known == id), "{id} not found");
}
assert_eq!(ids.iter().filter(|id| id.as_str() == "app.language.english").count(), 1, "one Language menu entry per language");
}
}