From 22eb4d3515c37077414f3a5fda14ff37ceda8f9a Mon Sep 17 00:00:00 2001 From: goujicheng Date: Wed, 7 Oct 2026 11:34:33 +0800 Subject: [PATCH 1/4] LR-BEHAV-LOCALIZE: a language table, Simplified Chinese, and per-script font fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The localization layer was hardcoded to English and Japanese: an enum with two variants, an `is_japanese()` gate compiled into the format macro, and one catalog pair. Adding a language meant editing the menus, the settings row, the checkmark logic, the format macro and the font stack — each in a different place. A language is now one entry in `i18n::language_table!`: BCP-47 code, endonym, ISO 15924 script and catalog. The Language menu, the settings row, the shortcut sheet and the control channel are all built from that table, and `build.rs` picks up `locales/-formats.json` by name, so a further language is a table entry plus two catalogs (docs/localization.md). Simplified Chinese (`zh-hans`) ships as the second translation: `zh-hans.json` (1019 entries, key-set and placeholder-identical to `ja.json`) and `zh-hans-formats.json` (102 entries). Its glyphs come from craft-fonts' Noto Sans CJK SC, whose `Hans` faces the UI now installs *before* the Japanese ones for a Chinese reader, so shared Han characters keep the language's forms (`fonts::cjk_fallback`); the export watermark accepts every CJK face. `tr_format!` keeps its compile-time check. It formats the English source at the call site — a runtime template cannot be handed to `format!` — and names those values so a translation can place them by name (`{n}`) or position (`{_1}`). Every translation is still checked by `format!`, so a translation with an unknown placeholder fails the build. The Japanese catalog was rewritten from the old `{:.0}` plural-discard trick to that by-name form; behaviour is unchanged. Also: settings persist the BCP-47 code (never the Rust variant name), `LIGHTCRAFT_LANGUAGE` accepts system-style tags (`zh_CN`, `ja_JP.UTF-8`), `xtask parity` reads the second command table, and the glyph tests cover every catalog character plus the interface symbols (`▸`/`▾` turned out to have no glyph in any installed font and are gone from the Chinese text). Chinese and Japanese both need `CRAFT_FONTS_DIR` as before; the web build still embeds only the Japanese face (Noto Sans CJK SC alone would push the wasm past Cloudflare's 25 MiB per-file cap), documented in docs/localization-zh-hans.md. Co-Authored-By: DeepSeek V3.2 --- README.md | 6 +- ROADMAP.md | 16 +- apps/lightcraft/src/native_menu.rs | 2 +- crates/engine/src/export.rs | 13 +- crates/engine/src/fonts.rs | 60 +- crates/ui-egui/build.rs | 97 +- crates/ui-egui/locales/ja-formats.json | 122 +-- crates/ui-egui/locales/zh-hans-formats.json | 104 ++ crates/ui-egui/locales/zh-hans.json | 1021 +++++++++++++++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/i18n.rs | 546 ++++++---- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menubar.rs | 6 +- crates/ui-egui/src/menus.rs | 32 +- crates/ui-egui/src/panels/dialogs.rs | 5 +- crates/ui-egui/src/panels/settings.rs | 3 +- crates/ui-egui/src/shortcuts.rs | 8 +- crates/ui-egui/src/state.rs | 2 +- crates/ui-egui/src/theme.rs | 28 +- docs/localization-ja.md | 13 +- docs/localization-zh-hans.md | 36 + docs/localization.md | 79 ++ docs/parity.md | 9 +- xtask/src/parity.rs | 15 +- 24 files changed, 1914 insertions(+), 313 deletions(-) create mode 100644 crates/ui-egui/locales/zh-hans-formats.json create mode 100644 crates/ui-egui/locales/zh-hans.json create mode 100644 docs/localization-zh-hans.md create mode 100644 docs/localization.md diff --git a/README.md b/README.md index e7454f24..403d3d3d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index c1cc9d8a..c6e0a42e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 8af250a5..33b5c867 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec)]) -> String { out.push(';'); } } - let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese()); + let mut s = format!("{};", lightcraft_ui_egui::i18n::uses_cjk_fonts()); for (t, items) in bar { s.push_str(t); walk(items, &mut s); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index ab0d8edf..6affb8b4 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -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> { - 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() } diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 954d9263..c028ba53 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,9 +1,9 @@ //! Fonts from the optional craft-fonts build input (storytold/craft-fonts). //! //! Built with `CRAFT_FONTS_DIR=`, [`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 impl Iterator { + 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); + } } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 75c3e03c..600997fd 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,18 +1,89 @@ -//! Compile translated format strings so Rust checks both languages' placeholders. +//! Compile the translated format strings into the `tr_format!` macro. +//! +//! `locales/-formats.json` maps an English format string to its translation for one language. +//! Each catalog becomes a `translated_format` match arm, so every language's placeholders are +//! checked by Rust's `format!` — a wrong placeholder is a compile error, not a runtime `{}`. +//! Adding a language means adding its file; this script picks it up by name. use std::{collections::BTreeMap, env, fs, path::PathBuf}; fn main() -> Result<(), Box> { - println!("cargo:rerun-if-changed=locales/ja-formats.json"); - let messages: BTreeMap = 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"); + let out = PathBuf::from(env::var("OUT_DIR")?); + fs::write(out.join("tr-formats.rs"), generate()?)?; Ok(()) } + +fn generate() -> Result> { + let mut catalogs = 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 messages: BTreeMap = serde_json::from_str(&fs::read_to_string(&path)?)?; + let variant = code.split(['-', '_']).filter(|part| !part.is_empty()).map(capitalize).collect::(); + catalogs.push((variant, messages)); + } + catalogs.sort_by(|a, b| a.0.cmp(&b.0)); + + // `tr_format!` formats the English source at the call site (where `format!` can check it) and + // also names every value, which is what lets a translation place them. Positional values are + // addressed as `_1`, `_2`, … in call-site order; `macro_rules!` cannot count, so the slot + // numbering is spelled out here and grows with `POSITIONAL_SLOTS` if a call site ever needs more. + const POSITIONAL_SLOTS: usize = 12; + let mut macro_source = String::from(concat!( + "/// Translate a format string with runtime values, keeping the English source's placeholders\n", + "/// checked by `format!` at the call site. `build.rs` writes this macro from `locales/`.\n", + "macro_rules! tr_format {\n", + " ($format:literal $(, $($args:tt)*)?) => {\n", + " $crate::i18n::tr_format!(@scan () () (_positional) ; $format; $($($args)*)?)\n", + " };\n", + " (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal; $name:ident = $value:expr $(, $($rest:tt)*)?) => {\n", + " $crate::i18n::tr_format!(@scan ($($raw)* $name = $value,) ($($values)* (stringify!($name), format!(\"{}\", $value)),)\n", + " $position ; $format; $($($rest)*)?)\n", + " };\n", + )); + for slot in 1..=POSITIONAL_SLOTS { + macro_source.push_str(&format!( + " (@scan ($($raw:tt)*) ($($values:tt)*) (_positional) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{slot}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n (@scan ($($raw:tt)*) ($($values:tt)*) (_{slot}) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{next}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n", + next = slot + 1 + )); + } + macro_source.push_str(concat!( + " (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal;) => {\n", + " $crate::i18n::format_message($format, format!($format, $($raw)*), &[$($values)*])\n", + " };\n", + "}\n", + "\n", + "// `macro_rules!` is textual: the module re-export is what lets call sites keep saying\n", + "// `crate::i18n::tr_format!`.\n", + "pub(crate) use tr_format;\n", + "\n", + "impl Locale {\n", + " /// The translated format string for `source`, or `None` when this language has none.\n", + " ///\n", + " /// Generated by `build.rs` from `locales/-formats.json`.\n", + " pub fn translated_format(self, source: &str) -> Option<&'static str> {\n", + " match self {\n", + )); + let mut source = macro_source; + for (variant, messages) in &catalogs { + source.push_str(&format!(" Locale::{variant} => match source {{\n")); + for (english, translated) in messages { + source.push_str(&format!(" {} => Some({}),\n", serde_json::to_string(english)?, serde_json::to_string(translated)?)); + } + source.push_str(" _ => None,\n },\n"); + } + source.push_str(" _ => None,\n }\n }\n}\n"); + Ok(source) +} + +/// `zh-hans` → `ZhHans`, the enum variant the `language_table!` entry declares. +fn capitalize(part: &str) -> String { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 9da942f8..467ebba8 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -1,19 +1,19 @@ { " · {skipped} skipped (file exists)": "・{skipped}件は同名ファイルのためスキップ", - " · {} duplicate{} skipped": "・重複{}枚をスキップ{:.0}", - " · {} failed: {}": "・{}件失敗:{}", - " · {} moved": "・{}件移動", - " · {} not readable": "・{}件は読み込み不可", - ", {failed} file{} not readable": "、{failed}件を読み込めません{:.0}", + " · {} duplicate{} skipped": "・重複{_1}枚をスキップ", + " · {} failed: {}": "・{_1}件失敗:{_2}", + " · {} moved": "・{_1}件移動", + " · {} not readable": "・{_1}件は読み込み不可", + ", {failed} file{} not readable": "、{failed}件を読み込めません", "Add {local_n} to My Photos": "{local_n}枚をマイフォトに追加", - "Add {} Photo{}": "{}枚の写真を追加{:.0}", - "Add “{}” to Favorites": "「{}」をお気に入りに追加", - "Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}", - "Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}", + "Add {} Photo{}": "{_1}枚の写真を追加", + "Add “{}” to Favorites": "「{_1}」をお気に入りに追加", + "Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました", + "Added {n} photo{} to “{}”": "「{_2}」に{n}枚を追加しました", "All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします", "Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用", - "Automatic ({} MB)": "自動({}MB)", - "Click {}:1": "クリック時 {}:1", + "Automatic ({} MB)": "自動({_1}MB)", + "Click {}:1": "クリック時 {_1}:1", "Create {n} Virtual Copies": "{n}枚の仮想コピーを作成", "Decoder: {reason}": "デコーダー:{reason}", "Delete \"{label}\"": "「{label}」を削除", @@ -22,83 +22,83 @@ "Delete “{current}”": "「{current}」を削除", "Duplicate \"{label}\"": "「{label}」を複製", "Example: {example}": "例:{example}", - "Exported {n} photo{}": "{n}枚を書き出しました{:.0}", - "Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました{:.0}", - "Exported {} point curve preset{}": "{}件のポイントカーブを書き出しました{:.0}", - "Exported {} preset{}": "{}件のプリセットを書き出しました{:.0}", - "Exporting {} of {total}": "書き出し中:{total}枚中{}枚", - "Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中{:.0}", - "Healed {n} dust spot{}": "{n}か所のほこりを修復しました{:.0}", - "Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました{:.0}", - "Imported {n} preset{}": "{n}件のプリセットを読み込みました{:.0}", - "Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました{:.0}。{f}件は読み込めません{:.0}", - "Imported {} photo{}": "{}枚を読み込みました{:.0}", - "Matches: {}": "一致条件:{}", - "Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%", + "Exported {n} photo{}": "{n}枚を書き出しました", + "Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました", + "Exported {} point curve preset{}": "{_1}件のポイントカーブを書き出しました", + "Exported {} preset{}": "{_1}件のプリセットを書き出しました", + "Exporting {} of {total}": "書き出し中:{total}枚中{_1}枚", + "Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中", + "Healed {n} dust spot{}": "{n}か所のほこりを修復しました", + "Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました", + "Imported {n} preset{}": "{n}件のプリセットを読み込みました", + "Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました。{f}件は読み込めません", + "Imported {} photo{}": "{_1}枚を読み込みました", + "Matches: {}": "一致条件:{_1}", + "Merging… {stage} {:.0}%": "合成中… {stage} %", "Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?", "Named ({named_n})": "名前付き({named_n})", - "Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)", + "Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け(コピーされた設定だけを適用)", "Preset: {name}": "プリセット:{name}", - "Preview only — editing the camera's embedded JPEG ({})": "プレビューのみ:カメラ内蔵のJPEGを編集中({})", - "Preview only — {}": "プレビューのみ:{}", - "Profile: {}": "プロファイル:{}", - "Proof Copy ({})": "校正用コピー({})", - "Proof Preview · {}": "校正プレビュー・{}", + "Preview only — editing the camera's embedded JPEG ({})": "プレビューのみ:カメラ内蔵のJPEGを編集中({_1})", + "Preview only — {}": "プレビューのみ:{_1}", + "Profile: {}": "プロファイル:{_1}", + "Proof Copy ({})": "校正用コピー({_1})", + "Proof Preview · {}": "校正プレビュー・{_1}", "Quick Develop · {n} photos": "クイック現像・{n}枚", - "Rated {}": "評価:{}", + "Rated {}": "評価:{_1}", "Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚", - "Redo {}": "やり直す:{}", - "Remove “{}” from Favorites": "「{}」をお気に入りから削除", + "Redo {}": "やり直す:{_1}", + "Remove “{}” from Favorites": "「{_1}」をお気に入りから削除", "Rename {n} Photos…": "{n}枚の写真の名前を変更…", "Rename “{name}”": "「{name}」の名前を変更", "Rendering on the CPU: {why}": "CPUで描画:{why}", - "Reset {} Channel": "{}チャンネルをリセット", - "Saved export preset “{}”": "書き出しプリセット「{}」を保存しました", - "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}", - "Showing {n} of {}": "{}枚中{n}枚を表示", - "Smart album: {}": "スマートアルバム:{}", - "Undo {}": "取り消す:{}", - "Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{} — Rust製の写真管理・RAW現像アプリ", - "Version: {}": "バージョン:{}", + "Reset {} Channel": "{_1}チャンネルをリセット", + "Saved export preset “{}”": "書き出しプリセット「{_1}」を保存しました", + "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示", + "Showing {n} of {}": "{_1}枚中{n}枚を表示", + "Smart album: {}": "スマートアルバム:{_1}", + "Undo {}": "取り消す:{_1}", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{_1} — Rust製の写真管理・RAW現像アプリ", + "Version: {}": "バージョン:{_1}", "none (CPU rendering): {why}": "なし(CPU描画):{why}", "the {n} selected photos": "選択中の{n}枚", - "the {} photos in view": "表示中の{}枚", + "the {} photos in view": "表示中の{_1}枚", "these {count} photos": "この{count}枚の写真", - "{count} smart preview{} · {:.1} MB": "スマートプレビュー{count}枚{:.0}・{:.1}MB", + "{count} smart preview{} · {:.1} MB": "スマートプレビュー{count}枚・{_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}:{1}か所中{0}か所", + "{mode} spot {} of {}": "{mode}:{_2}か所中{_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}件の変更{:.0}をディスクに保存できません:{e}\n自動で再試行します。終了すると変更が失われます{:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n}件の変更をディスクに保存できません:{e}\n自動で再試行します。終了すると変更が失われます", "{n} found · {sel} selected": "{n}枚検出・{sel}枚選択", "{n} photos": "{n}枚", - "{n} photos in {} are hidden by the filters above": "{}の{n}枚は上のフィルターで非表示になっています", - "{n} photo{}": "{n}枚{:.0}", - "{n} photo{} match · updates automatically as photos change": "{n}枚が一致{:.0}・写真の変更に応じて自動更新", - "{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新", + "{n} photos in {} are hidden by the filters above": "{_1}の{n}枚は上のフィルターで非表示になっています", + "{n} photo{}": "{n}枚", + "{n} photo{} match · updates automatically as photos change": "{n}枚が一致・写真の変更に応じて自動更新", + "{n} photo{} now · updates automatically as photos change": "現在{n}枚・写真の変更に応じて自動更新", "{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}", - "{stage}… {:.0}%": "{stage}… {:.0}%", + "{stage}… {:.0}%": "{stage}… %", "{what} merge added": "{what}合成を追加しました", "{what} merge failed: {e}": "{what}合成に失敗:{e}", - "{} and higher": "{}以上", - "{} files": "{}個のファイル", - "{} files and {f} folder{}": "{}個のファイルと{f}個のフォルダ{:.0}", - "{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{}本:垂直または水平にする線に沿ってドラッグ", - "{} photos": "{}枚", - "{} photo{} not readable": "{}枚を読み込めません{:.0}", - "{} spot(s) on this photo": "この写真に{}か所のスポット", + "{} and higher": "{_1}以上", + "{} files": "{_1}個のファイル", + "{} files and {f} folder{}": "{_1}個のファイルと{f}個のフォルダ", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{_1}本:垂直または水平にする線に沿ってドラッグ", + "{} photos": "{_1}枚", + "{} photo{} not readable": "{_1}枚を読み込めません", + "{} spot(s) on this photo": "この写真に{_1}か所のスポット", "· {dups} already in the library": "・{dups}枚は読み込み済み", - "· {n} photo{}": "・{n}枚{:.0}", + "· {n} photo{}": "・{n}枚", "… and {more} more": "…ほか{more}件", - "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}", - "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚", - "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚" + "Import cancelled · {} photo{} added": "読み込みを中止しました・{_1}枚を追加", + "Reading photos… {} of {}": "写真を確認中:{_2}枚中{_1}枚", + "Adding photos… {} of {}": "写真を追加中:{_2}枚中{_1}枚" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json new file mode 100644 index 00000000..a1e9f86c --- /dev/null +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -0,0 +1,104 @@ +{ + " · {skipped} skipped (file exists)": " · 已跳过 {skipped} 个(文件已存在)", + " · {} duplicate{} skipped": " · 已跳过 {_1} 个重复项", + " · {} failed: {}": " · {_1} 个失败:{_2}", + " · {} moved": " · 已移动 {_1} 个", + " · {} not readable": " · {_1} 个无法读取", + ", {failed} file{} not readable": ",{failed} 个文件无法读取", + "Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片", + "Add {} Photo{}": "添加 {_1} 张照片", + "Add “{}” to Favorites": "将“{_1}”添加到收藏夹", + "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片", + "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{_2}”", + "All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。", + "Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片", + "Automatic ({} MB)": "自动({_1} MB)", + "Click {}:1": "单击 {_1}: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} 张照片", + "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片", + "Exported {} point curve preset{}": "已导出 {_1} 个点曲线预设", + "Exported {} preset{}": "已导出 {_1} 个预设", + "Exporting {} of {total}": "正在导出 {total} 张中的第 {_1} 张", + "Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选", + "Healed {n} dust spot{}": "已修复 {n} 处尘点", + "Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设", + "Imported {n} preset{}": "已导入 {n} 个预设", + "Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取", + "Imported {} photo{}": "已导入 {_1} 张照片", + "Matches: {}": "匹配项:{_1}", + "Merging… {stage} {:.0}%": "合成中… {stage} %", + "Move {what} to Recently Deleted?": "将 {what} 移到最近删除?", + "Named ({named_n})": "已命名({named_n})", + "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置", + "Preset: {name}": "预设:{name}", + "Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({_1})", + "Preview only — {}": "仅预览 — {_1}", + "Profile: {}": "配置文件:{_1}", + "Proof Copy ({})": "校样副本({_1})", + "Proof Preview · {}": "校样预览 · {_1}", + "Quick Develop · {n} photos": "快速编辑 · {n} 张照片", + "Rated {}": "已评级 {_1}", + "Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张", + "Redo {}": "重做:{_1}", + "Remove “{}” from Favorites": "从收藏夹中移除“{_1}”", + "Rename {n} Photos…": "重命名 {n} 张照片…", + "Rename “{name}”": "重命名“{name}”", + "Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}", + "Reset {} Channel": "重置 {_1} 通道", + "Saved export preset “{}”": "已保存导出预设“{_1}”", + "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置", + "Showing {n} of {}": "显示 {_1} 张中的 {n} 张", + "Smart album: {}": "智能相册:{_1}", + "Undo {}": "撤销 {_1}", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {_1} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。", + "Version: {}": "版本:{_1}", + "none (CPU rendering): {why}": "无(CPU 渲染):{why}", + "the {n} selected photos": "选中的 {n} 张照片", + "the {} photos in view": "视图中的 {_1} 张照片", + "these {count} photos": "这 {count} 张照片", + "{count} smart preview{} · {:.1} MB": "{count} 个智能预览 · {_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}:第 {_1} 个污点,共 {_2} 个", + "{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 会自动重试;现在退出将丢失这些更改。", + "{n} found · {sel} selected": "找到 {n} 张 · 已选 {sel} 张", + "{n} photos": "{n} 张照片", + "{n} photos in {} are hidden by the filters above": "{_1} 中的 {n} 张照片被上方的过滤器隐藏", + "{n} photo{}": "{n} 张照片", + "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新", + "{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新", + "{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… %", + "{what} merge added": "已添加 {what} 合并", + "{what} merge failed: {e}": "{what} 合并失败:{e}", + "{} and higher": "{_1} 及以上", + "{} files": "{_1} 个文件", + "{} files and {f} folder{}": "{_1} 个文件和 {f} 个文件夹", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {_1} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。", + "{} photos": "{_1} 张照片", + "{} photo{} not readable": "{_1} 张照片无法读取", + "{} spot(s) on this photo": "此照片上有 {_1} 处污点", + "· {dups} already in the library": "· {dups} 张已在图库中", + "· {n} photo{}": "· {n} 张照片", + "… and {more} more": "…以及另外 {more} 项", + "Import cancelled · {} photo{} added": "导入已取消 · 已添加 {_1} 张照片", + "Reading photos… {} of {}": "正在读取照片… 第 {_1} 张,共 {_2} 张", + "Adding photos… {} of {}": "正在添加照片… 第 {_1} 张,共 {_2} 张" +} diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json new file mode 100644 index 00000000..3ebd08d2 --- /dev/null +++ b/crates/ui-egui/locales/zh-hans.json @@ -0,0 +1,1021 @@ +{ + "+ Group": "+ 组", + "+ Rule": "+ 条件", + "0: nothing is rejected.": "0:不排除任何照片。", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "筛选器正在隐藏此视图中的照片:请修改筛选条件,或清除筛选(视图 → 清除筛选)", + "About LightCraft": "关于 LightCraft", + "Activity": "活动", + "Add": "添加", + "Add Catchlight": "添加眼神光", + "Add Folder to Local": "将文件夹添加到本地", + "Add Selected Photos": "添加所选照片", + "Add a rule (⌥-click: a group)": "添加条件(按 ⌥ 单击:添加组)", + "Add in place": "在原位置添加", + "Add in place: the library references the files where they are; nothing is copied or moved.": "在原位置添加:图库直接引用文件所在位置;不会复制或移动任何内容。", + "Add keyword": "添加关键字", + "Add number": "添加编号", + "Add to": "添加到", + "Add to Album": "添加到相册", + "Add to Favorites": "添加到收藏夹", + "Add to My Photos": "添加到我的照片", + "Add to Selected Photos": "添加到所选照片", + "Add/Subtract/Intersect Mask": "添加/减去/相交蒙版", + "Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。", + "Adding": "正在添加", + "After": "修改后", + "After 30 days": "30 天后", + "After 7 days": "7 天后", + "After 90 days": "90 天后", + "After a year": "一年后", + "Album": "相册", + "Albums": "相册", + "All": "全部", + "All Metadata": "所有元数据", + "All Metadata…": "所有元数据…", + "All Photos": "所有照片", + "All rights reserved": "保留所有权利", + "All types": "所有类型", + "Alt Text": "替代文本", + "Always": "始终", + "Amount": "数量", + "Aperture": "光圈", + "Application": "应用程序", + "Apply Preset": "应用预设", + "Approved": "已批准", + "Aqua": "青色", + "Aqua Hue": "青色色相", + "Aqua Luminance": "青色明度", + "Aqua Saturation": "青色饱和度", + "As Shot": "拍摄时设置", + "As shot": "拍摄时设置", + "Ascending": "升序", + "Aspect": "长宽比", + "Aspect Ratio": "长宽比", + "Assisted Culling": "辅助筛选", + "Assisted Culling…": "辅助筛选…", + "Auto": "自动", + "Auto Advance": "自动前进", + "Auto Advance Off": "关闭自动前进", + "Auto Advance On": "开启自动前进", + "Auto Advance after rating or flagging": "评分或加旗标后自动前进", + "Auto Advance: move to the next photo after rating or flagging": "自动前进:评分或加旗标后移到下一张照片", + "Auto Align": "自动对齐", + "Auto Crop": "自动裁剪", + "Auto Import": "自动导入", + "Auto Mask": "自动蒙版", + "Auto Settings": "自动设置", + "Auto Sync": "自动同步", + "Auto-Stack by Capture Time": "按拍摄时间自动堆叠", + "Auto-Stack by Capture Time…": "按拍摄时间自动堆叠…", + "Auto-Tag from Tracklog": "从轨迹日志自动标记", + "Auto-Tag from Tracklog…": "从轨迹日志自动标记…", + "Automatic": "自动", + "Automatically Write Changes into XMP": "自动将更改写入 XMP", + "Automatically write changes into XMP sidecars": "自动将更改写入 XMP 附属文件", + "B&W": "黑白", + "B&W Mix": "黑白混合", + "B&W Mixer": "黑白混色器", + "Back": "返回", + "Back to Edit": "返回编辑", + "Back to Edit (E)": "返回编辑 (E)", + "Back to Grid": "返回网格视图", + "Background": "背景", + "Balance": "平衡", + "Before": "修改前", + "Before/After (Y)": "修改前/修改后 (Y)", + "Before/After Settings": "修改前/修改后设置", + "Before/After Split": "修改前/修改后分屏", + "Before/After Split Top/Bottom": "修改前/修改后上下分屏", + "Before/After Top/Bottom": "修改前/修改后上下排列", + "Bit depth": "位深度", + "Black & White": "黑白", + "Blacks": "黑色色阶", + "Blending": "混合", + "Blue": "蓝色", + "Blue Hue": "蓝色色相", + "Blue Luminance": "蓝色明亮度", + "Blue Primary": "蓝色原色", + "Blue Saturation": "蓝色饱和度", + "Blue channel": "蓝色通道", + "Blue/Yellow Fringe": "蓝/黄边缘", + "Blurry photos below the score are flagged as rejects.": "低于该分值的模糊照片将被标记为排除。", + "Boundary Warp": "边界变形", + "Bring All to Front": "全部置于顶层", + "Browse Folder…": "浏览文件夹…", + "Browse Profiles": "浏览配置文件", + "Browse…": "浏览…", + "Brush": "画笔", + "Brush Stroke": "画笔笔触", + "Build Previews": "构建预览", + "Build Smart Previews": "构建智能预览", + "By Date": "按日期", + "By day (YYYY/YYYY-MM-DD)": "按日 (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "按月 (YYYY/YYYY-MM)", + "Calibration": "校准", + "Camera": "相机", + "Cameras and Cards": "相机和存储卡", + "Cancel": "取消", + "Candidate": "候选", + "Caption": "题注", + "Capture Date": "拍摄日期", + "Captured": "拍摄时间", + "Check All": "全部选中", + "Choose Edit Settings to Copy": "选择要复制的编辑设置", + "Choose Edit Settings to Copy…": "选择要复制的编辑设置…", + "Choose Folder…": "选择文件夹…", + "Choose a photo in the grid or filmstrip": "请在网格视图或胶片窗格中选择一张照片", + "Choose…": "选择…", + "City": "城市", + "Clarity": "清晰度", + "Clear": "清除", + "Clear Cache": "清除缓存", + "Clear Filters": "清除过滤器", + "Clear Guides": "清除参考线", + "Clear History": "清除历史记录", + "Clear Quick Collection": "清空快速集合", + "Clear Reference": "清除参考照片", + "Clear all": "全部清除", + "Click Zoom Ratio": "单击缩放比例", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "单击标签即可插入。示例基于 2026 年 1 月 14 日 05:58:48 拍摄的 IMG_0042.CR3。", + "Click photos in the grid to toggle the keyword; Esc stops": "在网格中单击照片可切换关键字;按 Esc 结束", + "Click the photo to pick a colour · ⇧-click adds more": "单击照片选择颜色 · 按 ⇧ 单击可添加更多", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "单击照片选择颜色;按 ⇧ 单击可添加更多(最多 5 个)", + "Clone": "仿制", + "Close": "关闭", + "Close Panel": "关闭面板", + "Close Window": "关闭窗口", + "Cloudy": "阴天", + "Collapse All Stacks": "折叠所有堆叠", + "Color": "颜色", + "Color Grading": "颜色分级", + "Color Mixer": "颜色混合器", + "Color Noise Reduction": "颜色降噪", + "Color Priority": "颜色优先", + "Color Range": "颜色范围", + "Color space": "色彩空间", + "Color space: sRGB (AVIF)": "色彩空间:sRGB(AVIF)", + "Compare": "比较", + "Compare (Shift+C)": "比较(Shift+C)", + "Compare Before and After": "比较修改前和修改后", + "Compare needs at least two photos": "比较至少需要两张照片", + "Component options": "组件选项", + "Compression": "压缩", + "Confirm before deleting": "删除前确认", + "Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认", + "Constrain Crop": "限制裁剪", + "Contrast": "对比度", + "Copy": "复制", + "Copy After's Settings to Before": "将修改后的设置复制到修改前", + "Copy Before's Settings to After": "将修改前的设置复制到修改后", + "Copy Edit Settings": "复制编辑设置", + "Copy History Step Settings to Before": "将历史记录步骤的设置复制到修改前", + "Copy Metadata": "复制元数据", + "Copy as DNG": "拷贝为 DNG", + "Copy into the library (else use the files where they are)": "拷贝到图库中(否则使用文件当前位置)", + "Copy the files (into the library's Originals/, or a folder you choose)": "拷贝文件(到图库的 Originals/ 或您选择的文件夹)", + "Copy to": "拷贝到", + "Copy to Clipboard": "拷贝到剪贴板", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "拷贝:文件将拷贝到下方文件夹,图库使用这些副本;原始文件保持原样。", + "Copyright": "版权", + "Copyright Info URL": "版权信息 URL", + "Copyright Status": "版权状态", + "Country": "国家/地区", + "Create Album": "创建相册", + "Create Album…": "创建相册…", + "Create Folder": "创建文件夹", + "Create Folder…": "创建文件夹…", + "Create New Mask": "创建新蒙版", + "Create Preset": "创建预设", + "Create Preset…": "创建预设…", + "Create Proof Copy": "创建校样副本", + "Create Smart Album": "创建智能相册", + "Create Smart Album from Filter…": "从筛选器创建智能相册…", + "Create Smart Album…": "创建智能相册…", + "Create Stack": "创建堆叠", + "Create Version": "创建版本", + "Create Version from Step": "从该步骤创建版本", + "Create Virtual Copy": "创建虚拟副本", + "Creator": "创建者", + "Crop": "裁剪", + "Crop & Rotate": "裁剪和旋转", + "Crop & Rotate (C)": "裁剪和旋转(C)", + "Culling": "挑片", + "Curve": "曲线", + "Custom": "自定义", + "Cylindrical": "圆柱形", + "Darken": "压暗", + "Darks": "暗部", + "Date Groups": "日期分组", + "Day": "日", + "Daylight": "日光", + "Defringe": "去边", + "Deghost Amount": "去伪影量", + "Dehaze": "去朦胧", + "Delete": "删除", + "Delete (⌫)": "删除 (⌫)", + "Delete Album": "删除相册", + "Delete All Masks": "删除所有蒙版", + "Delete Keyword": "删除关键字", + "Delete Mask": "删除蒙版", + "Delete Permanently": "永久删除", + "Delete Photo": "删除照片", + "Delete Photos": "删除照片", + "Delete Preset": "删除预设", + "Delete Version": "删除版本", + "Delete all": "删除全部", + "Delete all spots": "删除所有污点", + "Delete mask": "删除蒙版", + "Delete them": "删除它们", + "Density": "密度", + "Depth Range": "深度范围", + "Descending": "降序", + "Deselect All": "取消全选", + "Detail": "细节", + "Detail (D)": "细节 (D)", + "Diagonal": "对角线", + "Discard Preview Cache": "丢弃预览缓存", + "Display P3": "Display P3", + "Distortion": "扭曲", + "Don't enlarge": "不放大", + "Done": "完成", + "Drag along the horizon; double-click for Auto": "沿地平线拖动;双击以自动校正", + "Drag over an eye on the photo; the pupil inside is found automatically.": "在照片中的眼睛上拖动;会自动找到其中的瞳孔。", + "Draw Guides": "绘制参考线", + "Duplicate": "创建副本", + "Duplicate Mask": "复制蒙版", + "Duplicate and Invert Mask": "复制并反转蒙版", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每个 / 开始一层文件夹;标签按照片填充(缺少元数据的层级为“unknown”)。", + "Edit": "编辑", + "Edit (E)": "编辑 (E)", + "Edit Capture Time": "编辑拍摄时间", + "Edit Capture Time…": "编辑拍摄时间…", + "Edit Color Label Names": "编辑颜色标签名称", + "Edit Label Names…": "编辑标签名称…", + "Edit Sections": "编辑板块", + "Edit Smart Album": "编辑智能相册", + "Edit Smart Album…": "编辑智能相册…", + "Edit in External Editor": "在外部编辑器中编辑", + "Edited": "已编辑", + "Effects": "效果", + "Enable Lens Corrections": "启用镜头校正", + "Enter Full Screen": "进入全屏", + "Erase": "擦除", + "Every EXIF, GPS and XMP field in the file": "文件中的每个 EXIF、GPS 和 XMP 字段", + "Existing previews": "现有预览", + "Expand All Stacks": "展开所有堆叠", + "Export": "导出", + "Export Album with Preset": "使用预设导出相册", + "Export Album…": "导出相册…", + "Export Group…": "导出分组…", + "Export Now": "立即导出", + "Export Point Curve Presets": "导出点曲线预设", + "Export Presets": "导出预设", + "Export Presets…": "导出预设…", + "Export User Presets…": "导出用户预设…", + "Export with Preset": "使用预设导出", + "Export with Previous": "使用上次设置导出", + "Exporting": "正在导出", + "Export…": "导出…", + "Exposure": "曝光", + "Exposure (shutter · aperture · ISO)": "曝光 (快门 · 光圈 · ISO)", + "Extended Description": "扩展描述", + "External Editor": "外部编辑器", + "Favorites": "收藏夹", + "Feather": "羽化", + "File": "文件", + "File & date": "文件 & 日期", + "File Name": "文件名", + "File Path": "文件路径", + "File Size": "文件大小", + "File name": "文件名", + "Fill Edges": "填充边缘", + "Filmstrip": "胶片窗格", + "Filter": "过滤器", + "Filter Bar": "过滤器栏", + "Filter Presets": "过滤器预设", + "Filter bar": "过滤器栏", + "Filter fields": "过滤字段", + "Find Dust Spots": "查找灰尘污点", + "Find Missing Photos…": "查找丢失的照片…", + "Find Similar Photos": "查找相似照片", + "Fit": "适合", + "Fit (F)": "适合 (F)", + "Flag": "旗标", + "Flag as Pick": "标记为留用", + "Flag as Reject": "标记为排除", + "Flash": "闪光灯", + "Flip H": "水平翻转", + "Flip Horizontal": "水平翻转", + "Flip Overlay (⇧O)": "翻转叠加 (⇧O)", + "Flip V": "垂直翻转", + "Flip Vertical": "垂直翻转", + "Flow": "流量", + "Fluorescent": "荧光灯", + "Focal length": "焦距", + "Folder": "文件夹", + "Folder name (renamed on disk; its photos follow)": "文件夹名称 (在磁盘上重命名;照片随之更新)", + "Folders": "文件夹", + "Folders and {date} use the capture time; a photo without one uses today's date.": "文件夹和{date}使用拍摄时间;没有拍摄时间的照片使用今天的日期。", + "Forget unchanged photos": "忘记未更改的照片", + "Format": "格式", + "Forward": "前进", + "Free": "自由", + "Full": "完整", + "Full Screen Preview": "全屏预览", + "Full size": "完整大小", + "GPS Track Log": "GPS 轨迹日志", + "General": "常规", + "Geometry": "几何", + "Global Hue": "全局色相", + "Global Luminance": "全局明亮度", + "Global Saturation": "全局饱和度", + "Glossy": "光面纸", + "Golden": "黄金比例", + "Grain": "颗粒", + "Graphic": "图形", + "Green": "绿色", + "Green Amount": "绿色量", + "Green Hue": "绿色色相", + "Green Hue High": "绿色色相上限", + "Green Hue Low": "绿色色相下限", + "Green Luminance": "绿色明亮度", + "Green Primary": "绿色原色", + "Green Saturation": "绿色饱和度", + "Green channel": "绿色通道", + "Grid": "网格视图", + "Grid Info": "网格信息", + "Group": "分组", + "Group by Day": "按日分组", + "Group by Month": "按月分组", + "Group by Year": "按年分组", + "Group by date": "按日期分组", + "Group into Stack": "合并为堆叠", + "Guided": "引导式", + "Guided Upright": "引导式透视校正", + "HDR Merge": "HDR 合并", + "HDR Panorama Merge": "HDR 全景合并", + "HDR Panorama…": "HDR 全景…", + "HDR…": "HDR…", + "Heal": "修复", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑", + "Height": "高度", + "Height (px)": "高度(px)", + "Help": "帮助", + "Hide LightCraft": "隐藏 LightCraft", + "Hide Mask": "隐藏蒙版", + "Hide Others": "隐藏其他", + "Hides this shortcut only; the folder and its photos stay as they are": "仅隐藏此快捷方式;文件夹及其中的照片保持不变", + "High": "高", + "Highlight": "高光", + "Highlights": "高光", + "Highlights Hue": "高光色相", + "Highlights Luminance": "高光明度", + "Highlights Saturation": "高光饱和度", + "Highlights split": "高光分界", + "Histogram": "直方图", + "History": "历史记录", + "History & Activity (Y)": "历史记录与活动(Y)", + "Horizontal": "水平", + "Hue": "色相", + "Hue Range": "色相范围", + "If file exists": "如果文件已存在", + "Import": "导入", + "Import Date": "导入日期", + "Import Photos": "导入照片", + "Import Photos…": "导入照片…", + "Import Point Curve Presets": "导入点曲线预设", + "Import Presets": "导入预设", + "Import Presets…": "导入预设…", + "Import Profiles & Presets…": "导入配置文件与预设…", + "Import from Device": "从设备导入", + "Import from Folder…": "从文件夹导入…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "使用“文件 → 导入照片…”(Cmd+Shift+I)导入照片,或将照片拖放到此处", + "Imported Photos": "已导入的照片", + "Importing": "正在导入", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "导入会扫描源中的照片,但不会将该文件夹添加到“本地”(若想在不导入的情况下处理某个文件夹,请使用“本地 → 浏览文件夹…”)。", + "In library": "在图库中", + "In memory — nothing is saved": "仅存于内存 — 不会保存任何内容", + "In use": "使用中", + "In-memory session: these settings last until LightCraft quits.": "内存会话:这些设置仅在 LightCraft 退出前有效。", + "Include subfolders": "包含子文件夹", + "Info": "信息", + "Info (I)": "信息(I)", + "Info overlay": "信息叠加显示", + "Interface": "界面", + "Intersect": "相交", + "Intersect with": "相交于", + "Into one folder": "合并到一个文件夹", + "Invert": "反相", + "Invert Mask": "反相蒙版", + "Keep in Local": "保留在本地", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "请选择空间充足的驱动器:智能预览每张照片约 1 MB。缩略图缓存仍保留在图库中。", + "Keyboard Shortcuts": "键盘快捷键", + "Keyboard shortcuts": "键盘快捷键", + "Keyword": "关键字", + "Keyword Painter": "关键字画笔", + "Keyword Set": "关键字集", + "Keyword Sets": "关键字集", + "Keyword Suggestions": "关键字建议", + "Keyword to paint": "要涂抹的关键字", + "Keywords": "关键字", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "您添加的关键字会显示在此处;按 ⌥1–⌥9 即可应用。", + "Landscape": "横向", + "Language": "语言", + "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "详细视图渲染的最大长边;在大显示器上越大越清晰,但速度更慢。", + "Last": "上次", + "Last view": "上次视图", + "Lateral Chromatic Aberration": "横向色差", + "Leave them": "保留", + "Lens": "镜头", + "Lens Corrections": "镜头校正", + "Level": "水平", + "Library": "图库", + "Library Originals": "图库原始文件", + "Light": "亮度", + "LightCraft Default": "LightCraft 默认", + "LightCraft Help": "LightCraft 帮助", + "LightCraft Preset": "LightCraft 预设", + "LightCraft Presets": "LightCraft 预设", + "LightCraft Website": "LightCraft 网站", + "LightCraft on GitHub": "GitHub 上的 LightCraft", + "LightCraft website": "LightCraft 网站", + "LightCraft — Second Window": "LightCraft — 第二窗口", + "Lights": "高光", + "Limit file size (KB, 0 = off)": "限制文件大小(KB,0 = 关闭)", + "Linear Gradient": "线性渐变", + "List this folder in Local from now on": "以后在本地中列出此文件夹", + "Local": "本地", + "Local folders": "本地文件夹", + "Local library — no cloud account needed": "本地图库 — 无需云账户", + "Locate Missing File…": "定位缺失的文件…", + "Location": "位置", + "Long Edge": "长边", + "Looking for photos…": "正在查找照片…", + "Lossless": "无损", + "Low": "低", + "Luminance": "明度", + "Luminance Range": "明度范围", + "Magenta": "品红色", + "Magenta Hue": "品红色色相", + "Magenta Luminance": "品红色明度", + "Magenta Saturation": "品红色饱和度", + "Make Candidate the Select": "将候选设为选择", + "Manual": "手动", + "Masking": "蒙版", + "Masking (M)": "蒙版 (M)", + "Masks": "蒙版", + "Match": "匹配", + "Matte": "哑光", + "Med": "中", + "Megapixels": "百万像素", + "Memory for caches": "缓存内存", + "Menu": "菜单", + "Merge": "合并", + "Merge Keywords": "合并关键字", + "Merge into…": "合并到…", + "Merging…": "正在合并…", + "Metadata": "元数据", + "Metadata Preset": "元数据预设", + "Metadata Presets": "元数据预设", + "Metadata preset": "元数据预设", + "Midpoint": "中点", + "Midtones": "中间调", + "Midtones Hue": "中间调色相", + "Midtones Luminance": "中间调明亮度", + "Midtones Saturation": "中间调饱和度", + "Midtones split": "中间调分割点", + "Minimize": "最小化", + "Missing Photos": "缺失的照片", + "Mode": "模式", + "Modified Date": "修改日期", + "Monochrome": "黑白", + "Month": "月", + "More": "更多", + "More preset options": "更多预设选项", + "Move": "移动", + "Move Down in Stack": "在堆叠中下移", + "Move Folder To…": "将文件夹移动到…", + "Move Up in Stack": "在堆叠中上移", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "移动文件 (移到图库的 Originals/,或你选择的文件夹),并从源位置移除", + "Move them": "移动它们", + "Move to": "移动到", + "Move to Group": "移动到组", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "移动:文件将移动到下面的文件夹;每个原始文件 (及其 XMP 附属文件) 仅在副本校验通过后才会从源位置移除。重复的文件和失败的文件保留在原处。", + "My Photos": "我的照片", + "Name": "名称", + "Named": "已命名", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "名称会显示在标签菜单、筛选栏和信息面板中,并写入 XMP。留空 = 颜色的名称。", + "Navigator": "导航器", + "Needs Work": "待处理", + "Neutral": "中性", + "Never": "从不", + "New Album…": "新建相册…", + "New Folder…": "新建文件夹…", + "New Group…": "新建组…", + "New Smart Album": "新建智能相册", + "New Smart Album from Filter…": "从筛选器新建智能相册…", + "New Smart Album…": "新建智能相册…", + "New album": "新建相册", + "New album…": "新建相册…", + "New name": "新名称", + "No": "否", + "No Camera or Card Found": "未找到相机或存储卡", + "No Date Groups": "不按日期分组", + "No Stars": "无星级", + "No Tool": "无工具", + "No automatic versions.": "没有自动版本。", + "No camera": "无相机信息", + "No date": "无日期", + "No dust spots found": "未找到污点", + "No edits yet.": "尚未编辑。", + "No exposure information": "无曝光信息", + "No filter presets yet": "暂无筛选器预设", + "No lens data embedded in this file (DNG lens opcodes only).": "此文件中未嵌入镜头数据 (仅支持 DNG 镜头操作码)。", + "No masks yet. Choose a mask type above.": "尚无蒙版。请在上方选择蒙版类型。", + "No matching photos": "没有匹配的照片", + "No metadata found": "未找到元数据", + "No new presets": "没有新预设", + "No photo selected": "未选择照片", + "No photos": "没有照片", + "No photos found": "未找到照片", + "No photos in this folder": "此文件夹中没有照片", + "No photos match the active filters": "没有照片符合当前筛选条件", + "No photos match the filters (View → Clear Filters)": "没有照片符合筛选条件 (视图 → 清除筛选)", + "No raw photos yet: cameras appear here once their photos are in the library.": "尚无 RAW 照片:照片进入图库后,相机会显示在此处。", + "No reference photo": "无参考照片", + "No usable GPU found: rendering on the CPU": "未找到可用的 GPU:改用 CPU 渲染", + "No versions yet. Create one to keep this look.": "尚无版本。创建一个以保留当前效果。", + "Noise": "噪点", + "Noise Reduction": "降噪", + "Non-raw photos": "非 RAW 照片", + "None": "无", + "Nothing to compare": "没有可比较的照片", + "Nothing to survey": "没有可筛选的照片", + "OK": "确定", + "Object": "对象", + "Off": "关闭", + "Offset X": "水平偏移", + "Offset Y": "垂直偏移", + "Opacity": "不透明度", + "Opacity (%)": "不透明度(%)", + "Open Library": "打开图库", + "Open Library…": "打开图库…", + "Open in": "打开方式", + "Open in Detail": "在放大视图中打开", + "Open the place in OpenStreetMap": "在 OpenStreetMap 中打开该地点", + "Optics": "镜头校正", + "Optional name": "名称(可选)", + "Orange": "橙色", + "Orange Hue": "橙色色相", + "Orange Luminance": "橙色明亮度", + "Orange Saturation": "橙色饱和度", + "Original": "原始比例", + "Original files": "原始文件", + "Original offline · editing the smart preview": "原图离线 · 正在编辑智能预览", + "Original offline · no smart preview": "原图离线 · 无智能预览", + "Originals": "原图", + "Other images (JPEG, PNG, TIFF, HEIC…)": "其他图像(JPEG、PNG、TIFF、HEIC…)", + "Overlay": "叠加显示", + "Overwrite": "覆盖", + "Paint": "绘制", + "Paint only areas like the one under the brush": "仅绘制与画笔下相似的区域", + "Paint over a distraction on the photo to remove it.": "在照片上要移除的干扰物上涂抹。", + "Paint overlay": "蒙版叠加显示", + "Panorama Merge": "全景合并", + "Panorama…": "全景…", + "Parametric curve": "参数曲线", + "Paste Edit Settings": "粘贴编辑设置", + "Paste Metadata": "粘贴元数据", + "Paste Selected Settings": "粘贴所选设置", + "Paste Selected Settings…": "粘贴所选设置…", + "Percent": "百分比", + "Percentage": "百分比", + "Performance": "性能", + "Perspective": "透视", + "Pet Eye": "宠物眼", + "Photo": "照片", + "Photo Grid": "照片网格", + "Photo Grid (G)": "照片网格(G)", + "Photo Merge": "照片合并", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "“照片 » 在外部编辑器中编辑”(⇧⌘E)会渲染一份 16 位 TIFF 副本,与原图堆叠并在此打开(macOS 上填应用名称,其他平台填程序路径)。", + "Photos": "照片", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "仅在“本地”中查看过、从未添加或更改的照片,若其文件夹在此时长内未被浏览,将移出目录(打开图库时检查)。文件保留在磁盘上;浏览该文件夹即可再次显示。", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "进入此文件夹的照片(联机拍摄、扫描仪、同步应用)在传输完成后即被添加。", + "Pick": "留用", + "Pick a colour on the photo with the eyedropper.": "用吸管在照片上取色。", + "Pick the sharpest photo of each burst": "为每组连拍选取最清晰的照片", + "Picked": "已留用", + "Picks": "留用", + "Pixels": "像素", + "Point Color": "点颜色", + "Point Curve": "点曲线", + "Point Curve Presets": "点曲线预设", + "Point curve": "点曲线", + "Portrait": "人像", + "Position": "位置", + "Preset": "预设", + "Preset name": "预设名称", + "Presets": "预设", + "Presets & Profiles": "预设和配置文件", + "Presets (Shift+P)": "预设 (Shift+P)", + "Presets ▾": "预设 ⌄", + "Preview": "预览", + "Preview only": "仅预览", + "Preview size": "预览大小", + "Previews": "预览", + "Print": "打印", + "Profile": "配置文件", + "Profile Distortion": "配置文件扭曲", + "Profile Vignetting": "配置文件暗角", + "Profile:": "配置文件:", + "Profiles": "配置文件", + "Projection": "投影", + "Public domain": "公有领域", + "Pupil Size": "瞳孔大小", + "Purple": "紫色", + "Purple Amount": "紫色量", + "Purple Hue": "紫色色相", + "Purple Hue High": "紫色色相上限", + "Purple Hue Low": "紫色色相下限", + "Purple Luminance": "紫色亮度", + "Purple Saturation": "紫色饱和度", + "Put the locations you removed from Local back (no files change)": "恢复你从本地移除的位置 (不更改文件)", + "Quality": "品质", + "Quick Collection": "快速集合", + "Quick Develop": "快速编辑", + "Quit LightCraft": "退出 LightCraft", + "Radial Gradient": "径向渐变", + "Radius": "半径", + "Range": "范围", + "Rating": "星级", + "Ratings & flags": "星级和旗标", + "Raw default": "RAW 默认值", + "Raw defaults": "RAW 默认值", + "Raw files": "RAW 文件", + "Raw photos": "RAW 照片", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "将 RAW 照片导出为内嵌编辑设置的 DNG。大小、颜色和输出选项不适用。", + "Read Metadata from File": "从文件读取元数据", + "Reading": "正在读取", + "Reading folder…": "正在读取文件夹…", + "Recently Added": "最近添加", + "Recently Deleted": "最近删除", + "Red": "红", + "Red Eye": "红眼", + "Red Hue": "红色色相", + "Red Luminance": "红色亮度", + "Red Primary": "红色原色", + "Red Saturation": "红色饱和度", + "Red channel": "红色通道", + "Red/Cyan Fringe": "红/青边缘", + "Redo": "重做", + "Reference": "参考", + "Reference View": "参考视图", + "Reference the files where they are": "在原地引用文件", + "Refresh Source (/)": "刷新源 (/)", + "Reject below focus": "排除对焦低于此值的照片", + "Rejected": "已排除", + "Rejects": "排除的照片", + "Remove": "移除", + "Remove (H)": "移除 (H)", + "Remove CA": "移除色差", + "Remove Chromatic Aberration": "移除色差", + "Remove from Album": "从相册中移除", + "Remove from Favorites": "从收藏夹中移除", + "Remove from Local": "从本地移除", + "Remove from Photo": "从照片中移除", + "Remove from Selected Photos": "从所选照片中移除", + "Remove from Stack": "从堆叠中移出", + "Remove from survey": "从筛选视图中移除", + "Remove location info": "移除位置信息", + "Remove this filter": "移除此筛选器", + "Remove this group": "删除此组", + "Remove this rule": "删除此规则", + "Rename": "重命名", + "Rename Album": "重命名相册", + "Rename Folder…": "重命名文件夹…", + "Rename Keyword": "重命名关键字", + "Rename Keyword…": "重命名关键字…", + "Rename Photos": "重命名照片", + "Rename Photos…": "重命名照片…", + "Rename Version": "重命名版本", + "Rename…": "重命名…", + "Rendering": "正在渲染", + "Rendering…": "正在渲染…", + "Reset": "重置", + "Reset All Curves": "重置所有曲线", + "Reset Before to Import State": "将修改前重置为导入时的状态", + "Reset Control": "重置控件", + "Reset Crop": "重置裁剪", + "Reset Edits": "重置编辑", + "Reset Parametric Curve": "重置参数曲线", + "Reset Section": "重置此区段", + "Reset Tone Curve": "重置色调曲线", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重置所有曲线:点曲线(所有通道)和参数曲线(双击某个通道可单独重置该通道)", + "Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)", + "Resize to": "调整大小为", + "Resolution (ppi)": "分辨率 (ppi)", + "Restore": "恢复", + "Retouch": "润饰", + "Review Import": "查看导入内容", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "右键单击照片 » 设为参考照片,然后选择“视图 → 参考视图”(Shift+R)", + "Right-click a preset to delete it": "右键单击预设即可将其删除", + "Rights Usage Terms": "使用条款", + "Rotate": "旋转", + "Rotate Left": "向左旋转", + "Rotate Right": "向右旋转", + "Roughness": "粗糙度", + "Roundness": "圆度", + "Same as raw default": "与 RAW 默认设置相同", + "Sample a colour on the photo": "在照片上取样颜色", + "Saturation": "饱和度", + "Saturation Range": "饱和度范围", + "Save": "保存", + "Save Current Filter as Preset…": "将当前筛选器保存为预设…", + "Save Current Keywords as Set…": "将当前关键字保存为集…", + "Save Metadata to File": "将元数据保存到文件", + "Save Point Curve…": "保存点曲线…", + "Save as Preset": "保存为预设", + "Save as Smart Album…": "保存为智能相册…", + "Save as set": "保存为集", + "Save preset": "保存预设", + "Scale": "缩放", + "Scanning": "正在扫描", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "评分会保留在照片上:可使用“对焦”和“相似照片中的最佳”进行筛选或创建智能相册。", + "Screen": "屏幕", + "Search Photos": "搜索照片", + "Second Window": "第二个窗口", + "Select": "选择", + "Select All": "全选", + "Select Luminance Range": "选择亮度范围", + "Select a photo to edit": "选择要编辑的照片", + "Select by": "选择方式", + "Select by Flag, Rating or Label": "按旗标、星级或颜色标签选择", + "Select photos, then choose View → Survey (N)": "选择照片,然后选择 视图 → 筛选 (N)", + "Select two photos, then choose View → Compare (Shift+C)": "选择两张照片,然后选择 视图 → 比较 (Shift+C)", + "Selected": "已选择", + "Send Feedback…": "发送反馈…", + "Services": "服务", + "Set Color Label": "设置颜色标签", + "Set Develop Values": "设置编辑参数", + "Set Flag": "设置旗标", + "Set Point Curve": "设置点曲线", + "Set Profile": "设置配置文件", + "Set Rating": "设置星级", + "Set as Album Cover": "设为相册封面", + "Set as Before": "设为修改前", + "Set as Reference Photo": "设为参考照片", + "Set as Target Album": "设为目标相册", + "Set as Target Album (B adds to it)": "设为目标相册 (按 B 添加)", + "Set as Top of Stack": "设为堆叠顶部", + "Set the mix from the photo's colours": "根据照片颜色设置混合", + "Settings": "设置", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片的初始设置。更改这些设置不会影响图库中已有的照片。", + "Settings to include": "要包含的设置", + "Settings…": "设置…", + "Shade": "阴影", + "Shadow": "阴影", + "Shadows": "阴影", + "Shadows Hue": "阴影色相", + "Shadows Luminance": "阴影明亮度", + "Shadows Saturation": "阴影饱和度", + "Shadows Tint": "阴影色调", + "Shadows split": "阴影分界", + "Sharpen": "锐化", + "Sharpening": "锐化", + "Sharpness": "锐度", + "Short Edge": "短边", + "Show All": "显示全部", + "Show Clipping": "显示溢出警告", + "Show Deghost Overlay": "显示去重影叠加", + "Show Hidden Local Locations": "显示隐藏的本地位置", + "Show Luminance Map": "显示明亮度图", + "Show Mask": "显示蒙版", + "Show Mask Overlay": "显示蒙版叠加", + "Show Mask Pins": "显示蒙版图钉", + "Show Original": "显示原图", + "Show Photo Counts": "显示照片数量", + "Show Photos with Keyword": "显示包含该关键字的照片", + "Show Pins": "显示图钉", + "Show Source": "显示来源", + "Show Thumbnails": "显示缩略图", + "Show destination gamut warning": "显示目标色域警告", + "Show display gamut warning": "显示显示器色域警告", + "Show file names": "显示文件名", + "Show in Finder": "在 Finder 中显示", + "Show on Map": "在地图上显示", + "Show overlay (O)": "显示叠加 (O)", + "Show ratings, flags and edit badges": "显示星级、旗标和编辑标记", + "Show the Navigator while zoomed in": "放大时显示导航器", + "Show the template tags; click one to insert it": "显示模板标签;单击即可插入", + "Show/hide My Photos panel": "显示/隐藏我的照片面板", + "Shutter speed": "快门速度", + "Sidecar names": "附属文件名", + "Size": "大小", + "Size (% of short edge)": "大小 (占短边 %)", + "Size limit": "大小限制", + "Skip": "跳过", + "Sky": "天空", + "Smart Album": "智能相册", + "Smart previews": "智能预览", + "Smoothness": "平滑度", + "Soft Proofing": "软打样", + "Sort": "排序", + "Sort by": "排序方式", + "Source": "来源", + "Source code on GitHub": "GitHub 上的源代码", + "Spherical": "球面", + "Spiral": "螺旋", + "Square Grid": "正方形网格", + "Square Grid (G toggles)": "正方形网格(按 G 切换)", + "Square Grid: show file names and formats": "正方形网格:显示文件名和格式", + "Stack": "堆叠", + "Stack photos taken within this time of each other:": "将拍摄时间在此间隔内的照片堆叠在一起:", + "Stacks": "堆叠", + "Standard": "标准", + "Start at": "起始编号", + "Start number": "起始编号", + "Startup": "启动", + "State / Province": "省 / 州", + "Stop": "停止", + "Stop Building Previews": "停止构建预览", + "Stop Using as Target Album": "不再用作目标相册", + "Stopping…": "正在停止…", + "Straighten": "拉直", + "Straighten Tool": "拉直工具", + "Style": "样式", + "Subfolder": "子文件夹", + "Subfolder name": "子文件夹名称", + "Subject": "主题", + "Subtract": "减去", + "Suggestions": "建议", + "Survey": "筛选视图", + "Survey (N)": "筛选视图(N)", + "Swap": "交换", + "Swap Aspect (X)": "交换长宽比(X)", + "Swap Before and After Settings": "交换修改前和修改后的设置", + "Sync Settings": "同步设置", + "System Info": "系统信息", + "System Info…": "系统信息…", + "System default": "系统默认", + "Tags": "标签", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "标签:请参阅模板旁的“标签”。文件将在磁盘上重命名(连同其 XMP 附属文件);已有同名文件会得到 -1、-2…", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "局部调整:在照片上上下拖动以调整该处的颜色", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "局部调整:在照片上上下拖动以调整该处的曲线", + "Temp": "色温", + "Temperature": "色温", + "Template": "模板", + "Text": "文字", + "Texture": "纹理", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "保留原始文件不变,每个文件附带一个保存其编辑内容的 XMP 附属文件。", + "The photo has these settings": "此照片具有这些设置", + "They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。", + "Thirds": "三分法", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此文件夹不可用(驱动器已断开连接?)。在它恢复或您选择其他文件夹之前,不会构建或使用智能预览。", + "This photo can't be opened": "无法打开此照片", + "Threshold": "阈值", + "Thumbnail cache": "缩略图缓存", + "Tint": "色调", + "Title": "标题", + "Tone Curve": "色调曲线", + "Tools": "工具", + "Top Level": "顶层", + "Transfer": "传输", + "Treatment": "处理方式", + "Triangle": "三角形", + "Tungsten": "钨丝灯", + "Turn Off": "关闭", + "Turn on Include subfolders, or pick another folder under Local": "开启“包括子文件夹”,或在“本地”下选择其他文件夹", + "Uncheck All": "全部取消选中", + "Undo": "撤销", + "Unedited": "未编辑", + "Unflag": "取消旗标", + "Unflagged": "无旗标", + "Ungroup Stack": "取消堆叠", + "Unrated": "未评级", + "Unreadable": "无法读取", + "Update": "更新", + "Update Rules from Current Filter": "从当前筛选器更新规则", + "Update Version": "更新版本", + "Update with Current Settings": "使用当前设置更新", + "Upright": "垂直校正", + "Use Library Folder": "使用图库文件夹", + "Use camera-specific defaults": "使用相机专属默认值", + "Use the GPU for rendering": "使用 GPU 渲染", + "User Presets": "用户预设", + "Value": "值", + "Variance": "变化幅度", + "Version": "版本", + "Version name": "版本名称", + "Versions": "版本", + "Versions (Shift+V)": "版本 (Shift+V)", + "Vertical": "垂直", + "Vibrance": "鲜艳度", + "Videos": "视频", + "View": "视图", + "Vignette": "暗角", + "Vignetting": "暗角", + "Visualize Spots": "可视化斑点", + "Visualize Spots (A)": "可视化斑点 (A)", + "Visualize range": "显示范围", + "Vivid": "鲜艳", + "Watched folder": "监视的文件夹", + "Watermark": "水印", + "What's New": "新功能", + "When rated or hovered": "评级或悬停时", + "White Balance": "白平衡", + "White Balance Selector": "白平衡选择器", + "White Balance Selector (W)": "白平衡选择器 (W)", + "Whites": "白色色阶", + "Width": "宽度", + "Width (% of photo)": "宽度(照片百分比)", + "Width (px)": "宽度 (px)", + "Width × Height": "宽度 × 高度", + "Window": "窗口", + "XMP sidecars": "XMP 附属文件", + "Year": "年", + "Yellow": "黄色", + "Yellow Hue": "黄色色相", + "Yellow Luminance": "黄色明度", + "Yellow Saturation": "黄色饱和度", + "Yes": "是", + "Zoom": "缩放", + "Zoom 100%": "缩放 100%", + "Zoom In": "放大", + "Zoom Out": "缩小", + "Zoom to Fit": "适合窗口", + "and": "和", + "comma, separated": "逗号分隔", + "memory only": "仅内存", + "none": "无", + "not available here": "此处不可用", + "of the following rules:": "以下规则:", + "Effect": "效果", + "Image": "图像", + "Layer": "图层", + "Type": "类型", + "Save As…": "另存为…", + "Exit": "退出", + "New…": "新建…", + "New": "新建", + "Orientation": "方向", + "Layers": "图层", + "Properties": "属性", + "Brush Settings": "画笔设置", + "Options": "选项", + "Fit on Screen": "适合屏幕", + "Cut": "剪切", + "Paste": "粘贴", + "Deselect": "取消选择", + "Export As…": "导出为…", + "Search…": "搜索…", + "Theme": "主题", + "Pages": "页面", + "Preferences": "首选项", + "Preferences…": "首选项…", + "Interface language": "界面语言", + "Open…": "打开…", + "New blank PDF": "新建空白 PDF", + "Create PDF from file…": "从文件创建 PDF…", + "Create PDF from images…": "从图像创建 PDF…", + "Create PDF from clipboard": "从剪贴板创建 PDF", + "Combine files…": "合并文件…", + "Save as…": "另存为…", + "Close file": "关闭文件", + "Close all": "全部关闭", + "Revert": "恢复", + "Print…": "打印…", + "Document properties…": "文档属性…", + "Find…": "查找…", + "Advanced search…": "高级搜索…", + "Copy pages": "复制页面", + "Cut pages": "剪切页面", + "Paste pages": "粘贴页面", + "Fit visible": "适合可见区域", + "Marquee zoom": "框选缩放", + "Take a snapshot": "创建快照", + "Full screen mode": "全屏模式", + "Read mode": "阅读模式", + "Switch light / dark theme": "切换浅色/深色主题", + "Comments panel": "注释面板", + "Form fields panel": "表单字段面板", + "Clear form": "清除表单", + "Find tools and commands…": "查找工具和命令…", + "Actual size": "实际大小", + "Zoom to page level": "缩放至页面级别", + "Fit to width": "适合宽度", + "Display theme": "显示主题", + "Side panels": "侧面板", + "Text direction": "文字方向", + "Horizontal text": "横排文字", + "Vertical text": "竖排文字", + "Your library couldn't be opened": "无法打开图库", + "LightCraft couldn't find where to keep your library.": "LightCraft 找不到保存图库的位置。", + "LightCraft couldn't open the library at": "LightCraft 无法打开以下位置的图库:", + "Try Again": "重试", + "Choose Another Library…": "选择其他图库…", + "Continue Without Saving": "不保存继续", + "Quit": "退出", + "Temporary session — your library isn't open, and nothing you do here is saved.": "临时会话 — 图库未打开,此处所做的任何操作都不会保存。", + "Back Up Library…": "备份图库…", + "Restore Library from Backup…": "从备份恢复图库…" +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9e1f0925..d3898065 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -55,7 +55,7 @@ fn wrap(r: Result) -> Outcome { pub fn all_commands(app: &LightcraftApp) -> Value { let mut v: Vec = 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) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0e2389a4..55f52d52 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1,224 +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/.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 { + static CATALOGS: OnceLock>> = OnceLock::new(); + static EMPTY: OnceLock> = 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 { + 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 { + 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/.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(&self, serializer: S) -> Result { + serializer.serialize_str(self.code()) } - pub fn parse(code: &str) -> Option { - 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>(deserializer: D) -> Result { + 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 = const { Cell::new(Language::En) }; + static LOCALE: std::cell::Cell = 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) +pub fn language() -> Locale { + LOCALE.with(std::cell::Cell::get) } -fn japanese() -> &'static BTreeMap { - static MESSAGES: OnceLock> = 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() - }) - }) +/// Whether the active language needs the craft-fonts CJK faces (Inter alone has no CJK glyphs): +/// the native menu bar uses it to pick a text style. +pub fn uses_cjk_fonts() -> bool { + language().script() != "Latn" } /// 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>> = 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 { + language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() +} + +/// Whether the language translates anything at all (the `tr_format!` fast path). +pub fn has_translations(language: Locale) -> bool { + !language.catalog().is_empty() +} + +/// Translate a format string with runtime values, as `tr_format!` calls it. +/// +/// `english` is the source text already formatted with the call site's values, and `values` names +/// those values so the translation can place them. A message with no translation, or a translation +/// without placeholders, keeps `english` as it is. +pub fn format_message(source: &str, english: String, values: &[(&str, String)]) -> String { + let Some(translated) = language().translated_format(source) else { + return english; + }; + if !translated.contains('{') { + return translated.to_string(); + } + let mut out = String::with_capacity(translated.len()); + let mut rest = translated; + while let Some(start) = rest.find('{') { + let Some(end) = rest[start..].find('}') else { break }; + let field = &rest[start + 1..start + end]; + let (name, _) = field.split_once(':').unwrap_or((field, "")); + out.push_str(&rest[..start]); + // The catalogs spell every value out by name; anything else (a `{}` that only the English + // text used, or a name no call site passes) is left exactly as the translator wrote it. + match values.iter().find(|(key, _)| *key == name) { + Some((_, value)) => out.push_str(value), + None => out.push_str(&rest[start..start + end + 1]), + } + rest = &rest[start + end + 1..]; + } + out.push_str(rest); + out +} + +// `tr_format!` (one arm per language) and the translated-format lookup are generated by +// `build.rs`; `Locale` is declared above, so the generated `impl Locale` has it in scope. +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 = 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 = 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::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() { + // The English source is what `format!` checks at the call site; each translation then places + // the same values by name (`{n}`, `{total}`) or by position (`{_1}`, `{_2}`). + 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"); + set_language(Locale::En); + // A format no catalog carries keeps its values, and one with no values keeps its text. + assert_eq!(tr_format!("{n} uncatalogued {thing}", n = 3, thing = "things"), "3 uncatalogued things"); + assert_eq!(tr_format!("Activity"), "Activity"); + } + + /// 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 = 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::() + ); + } + }); + } + } + + /// 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.zh-hans", 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); - } - - #[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 collect(shape: &egui::epaint::Shape, text: &mut String) { - match shape { - egui::epaint::Shape::Text(shape) => { - text.push_str(&shape.galley.job.text); - text.push('\n'); - } - egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), - _ => {} - } - } - 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. - 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::>(); - let japanese_ids = ids(&app); - set_language(Language::En); - assert_eq!(japanese_ids, ids(&app)); - } - /// 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(); - ctx.set_fonts(crate::theme::font_definitions(craft)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - 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. - #[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()); - } - }); - } - - /// Built without craft-fonts, the UI (in Japanese, too) still installs its fonts and runs; - /// Latin text keeps Inter. - #[test] - fn the_ui_works_without_craft_fonts() { - let ctx = fonts_ctx(&[]); - ctx.fonts_mut(|fonts| { - // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's - // replacement-glyph face, which there is Hack, the face that draws Latin.) - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); - } - }); - 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(); - } - set_language(Language::En); + assert_eq!(restored.language, Locale::ZhHans); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8addc61b..9f688919 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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)] diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e..9352a9c1 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -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 @@ -270,8 +270,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { 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), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5d838b75..92910324 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -10,6 +10,31 @@ 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. +const LANGUAGE_COMMANDS: &[UiCommand] = &[ + ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), + ("app.language.zh-hans", 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 { + 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 { + match id { + "app.language.english" => Some(crate::i18n::Locale::En), + "app.language.zh-hans" => 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"), @@ -206,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> { - 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(); @@ -1251,8 +1276,7 @@ pub struct MenuEntry { /// The flattened menu model (UI commands + engine commands with menu paths). pub fn menu_entries(app: &LightcraftApp) -> Vec { - let mut v: Vec = UI_COMMANDS - .iter() + let mut v: Vec = ui_commands() .filter(|c| !c.3.is_empty()) .map(|(id, label, sc, m)| MenuEntry { id: id.to_string(), diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fe8b0478..1ea18d0e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -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)) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index a380090e..e191668d 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -91,7 +91,8 @@ fn choices(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")); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 07665dad..c5d2ce16 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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())); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1a4446e3..f9d8c921 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -201,7 +201,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)>, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b5db692d..c77fb0a2 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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::>() + lightcraft_engine::fonts::cjk_fallback(craft, crate::i18n::language().script(), style) + .into_iter() + .map(craft_font_name) + .collect::>() }; + 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 = 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 diff --git a/docs/localization-ja.md b/docs/localization-ja.md index 4d1aa627..9dfa998e 100644 --- a/docs/localization-ja.md +++ b/docs/localization-ja.md @@ -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,13 +11,18 @@ 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のフォーマット検査を受けます。 -英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。 + +可変文言は、呼び出し側の英語の書式を `format!` がそのまま検査し、その値を翻訳側が +名前(`{n}`、`{total}`)または位置(`_1`、`_2` …)で参照します。英語の単複数語尾のように +中国語・日本語で不要な値は、**単に参照しないだけ**です(`{:.0}` のような空の引数は不要)。 +両言語の訳文はビルド時に `format!` の検査を受けるため、存在しない名前を書くとコンパイルエラーになります。 `LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。 表示・フォント・言語の切り替え・設定の保存・コマンドIDの保持は diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md new file mode 100644 index 00000000..f643c2aa --- /dev/null +++ b/docs/localization-zh-hans.md @@ -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`。两者都以英文原文为键。 + +含数值的文字遵循以下约定: + +- 调用点的英文原文由 `format!` 检查(占位符写错会编译失败),翻译再按名字或位置引用同一个值。 +- **按名字**:`{n}`、`{label}`、`{total}` 等与英文原文中的名字一致。 +- **按位置**:`_1`、`_2`、`_3` … 对应调用点参数的先后顺序(从 1 开始),用于调整语序, + 例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{_2}”`。 +- 英文用来表示单复数的那个参数(`"s"` / `""`)在中文里**不需要写**,直接不引用即可; + 不需要 `{:.0}` 之类的空占位符。 +- 译文里的占位符同样会被 `format!` 检查:写了不存在的名字就编译不过。 + +`LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。 + +翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由 +`cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。 diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 00000000..da3ff77d --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,79 @@ +# 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/.json` and `locales/-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 `-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`. It formats the **English source at the call +site**, which is what gives `format!` a literal to check against that call site's own arguments, and +it also names those values so a translation can place them: + +```rust +tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album, n = n) +``` + +- A translation refers to a value **by name** exactly as the English source spells it (`{n}`), or + **by position** as `_1`, `_2`, … in call-site order. Reordering is fine: + `"已将 {n} 张照片添加到“{_2}”"`. +- The English plural suffix (`"s"` / `""`) is simply not referenced in languages without plurals. +- Every translation is placeholder-checked at compile time: an unknown name, or a spec that does not + match its value, fails the build rather than printing `{}` at runtime. +- A source string with no translation falls back to the English text, values included. + +## 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.zh-hans"}`) 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. diff --git a/docs/parity.md b/docs/parity.md index fc94d08a..d1514c9f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 | 3 | 2 | 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 | 9 | 40 | 9 | — | 21/22 (95%) | -| **Total** | 387 | 29 | 91 | 37 | 194/200 (97%) | 139/149 (93%) | +| **Total** | 388 | 30 | 90 | 37 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.2% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.3%** of 508 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.9% of 159. ## 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.zh-hans`, `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 | ⬜ | | | diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index caa3932a..3e59a201 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -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 { - 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, id: &str) -> bool { From 313c555b5040aea97e3a67b4f5a522418ddaec98 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:13:44 -0400 Subject: [PATCH 2/4] LR-BEHAV-LOCALIZE: compile-checked tr_format! for every language catalog Restore main's compile-checked formatting, generalised to N catalogs: build.rs writes one tr_format! arm per English message that matches on the active language and calls format! with that language's translation (English is the fallback arm). The runtime substitution dropped format specs ({:.1}, {:+}, {:.0}%), e.g. the smart-preview size and the merge progress percentage. - formats catalogs use main's convention again ({0}/{1} for reordering, {:.0} to consume an unused plural suffix); zh-hans converted, with the size and percentage placeholders fixed by hand - build.rs: auto-discovers locales/*-formats.json, validates the code (^[a-z]+(-[a-z]+)*$), fails when the catalogs' keys differ, and reruns when the locales directory changes - zh-hans: translate the strings main added since the PR branched - test: precision, sign and reordered positional values survive in every language Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/build.rs | 147 +++++++++++--------- crates/ui-egui/locales/zh-hans-formats.json | 125 +++++++++-------- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/src/i18n.rs | 60 +++----- docs/localization-ja.md | 7 +- docs/localization-zh-hans.md | 12 +- docs/localization.md | 21 +-- 7 files changed, 183 insertions(+), 193 deletions(-) diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 600997fd..ebdfa047 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,85 +1,41 @@ //! Compile the translated format strings into the `tr_format!` macro. //! //! `locales/-formats.json` maps an English format string to its translation for one language. -//! Each catalog becomes a `translated_format` match arm, so every language's placeholders are -//! checked by Rust's `format!` — a wrong placeholder is a compile error, not a runtime `{}`. -//! Adding a language means adding its file; this script picks it up by name. -use std::{collections::BTreeMap, env, fs, path::PathBuf}; +//! 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> { 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(()) } -fn generate() -> Result> { - let mut catalogs = 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 messages: BTreeMap = serde_json::from_str(&fs::read_to_string(&path)?)?; - let variant = code.split(['-', '_']).filter(|part| !part.is_empty()).map(capitalize).collect::(); - catalogs.push((variant, messages)); +/// `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 { + 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]+)*$")); } - catalogs.sort_by(|a, b| a.0.cmp(&b.0)); - - // `tr_format!` formats the English source at the call site (where `format!` can check it) and - // also names every value, which is what lets a translation place them. Positional values are - // addressed as `_1`, `_2`, … in call-site order; `macro_rules!` cannot count, so the slot - // numbering is spelled out here and grows with `POSITIONAL_SLOTS` if a call site ever needs more. - const POSITIONAL_SLOTS: usize = 12; - let mut macro_source = String::from(concat!( - "/// Translate a format string with runtime values, keeping the English source's placeholders\n", - "/// checked by `format!` at the call site. `build.rs` writes this macro from `locales/`.\n", - "macro_rules! tr_format {\n", - " ($format:literal $(, $($args:tt)*)?) => {\n", - " $crate::i18n::tr_format!(@scan () () (_positional) ; $format; $($($args)*)?)\n", - " };\n", - " (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal; $name:ident = $value:expr $(, $($rest:tt)*)?) => {\n", - " $crate::i18n::tr_format!(@scan ($($raw)* $name = $value,) ($($values)* (stringify!($name), format!(\"{}\", $value)),)\n", - " $position ; $format; $($($rest)*)?)\n", - " };\n", - )); - for slot in 1..=POSITIONAL_SLOTS { - macro_source.push_str(&format!( - " (@scan ($($raw:tt)*) ($($values:tt)*) (_positional) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{slot}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n (@scan ($($raw:tt)*) ($($values:tt)*) (_{slot}) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{next}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n", - next = slot + 1 - )); - } - macro_source.push_str(concat!( - " (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal;) => {\n", - " $crate::i18n::format_message($format, format!($format, $($raw)*), &[$($values)*])\n", - " };\n", - "}\n", - "\n", - "// `macro_rules!` is textual: the module re-export is what lets call sites keep saying\n", - "// `crate::i18n::tr_format!`.\n", - "pub(crate) use tr_format;\n", - "\n", - "impl Locale {\n", - " /// The translated format string for `source`, or `None` when this language has none.\n", - " ///\n", - " /// Generated by `build.rs` from `locales/-formats.json`.\n", - " pub fn translated_format(self, source: &str) -> Option<&'static str> {\n", - " match self {\n", - )); - let mut source = macro_source; - for (variant, messages) in &catalogs { - source.push_str(&format!(" Locale::{variant} => match source {{\n")); - for (english, translated) in messages { - source.push_str(&format!(" {} => Some({}),\n", serde_json::to_string(english)?, serde_json::to_string(translated)?)); - } - source.push_str(" _ => None,\n },\n"); - } - source.push_str(" _ => None,\n }\n }\n}\n"); - Ok(source) + Ok(code.split('-').map(capitalize).collect()) } -/// `zh-hans` → `ZhHans`, the enum variant the `language_table!` entry declares. fn capitalize(part: &str) -> String { let mut chars = part.chars(); match chars.next() { @@ -87,3 +43,58 @@ fn capitalize(part: &str) -> String { None => String::new(), } } + +fn generate() -> Result> { + let mut catalogs: Vec<(String, String, BTreeMap)> = 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 = + 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) +} diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index a1e9f86c..c30f92ea 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -1,19 +1,19 @@ { " · {skipped} skipped (file exists)": " · 已跳过 {skipped} 个(文件已存在)", - " · {} duplicate{} skipped": " · 已跳过 {_1} 个重复项", - " · {} failed: {}": " · {_1} 个失败:{_2}", - " · {} moved": " · 已移动 {_1} 个", - " · {} not readable": " · {_1} 个无法读取", - ", {failed} file{} not readable": ",{failed} 个文件无法读取", + " · {} 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{}": "添加 {_1} 张照片", - "Add “{}” to Favorites": "将“{_1}”添加到收藏夹", - "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片", - "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{_2}”", + "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)": "自动({_1} MB)", - "Click {}:1": "单击 {_1}:1", + "Automatic ({} MB)": "自动({0} MB)", + "Click {}:1": "单击 {0}:1", "Create {n} Virtual Copies": "创建 {n} 个虚拟副本", "Decoder: {reason}": "解码器:{reason}", "Delete \"{label}\"": "删除 \"{label}\"", @@ -22,83 +22,86 @@ "Delete “{current}”": "删除“{current}”", "Duplicate \"{label}\"": "复制 \"{label}\"", "Example: {example}": "示例:{example}", - "Exported {n} photo{}": "已导出 {n} 张照片", - "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片", - "Exported {} point curve preset{}": "已导出 {_1} 个点曲线预设", - "Exported {} preset{}": "已导出 {_1} 个预设", - "Exporting {} of {total}": "正在导出 {total} 张中的第 {_1} 张", - "Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选", - "Healed {n} dust spot{}": "已修复 {n} 处尘点", - "Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设", - "Imported {n} preset{}": "已导入 {n} 个预设", - "Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取", - "Imported {} photo{}": "已导入 {_1} 张照片", - "Matches: {}": "匹配项:{_1}", - "Merging… {stage} {:.0}%": "合成中… {stage} %", + "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} 张照片 — 仅粘贴已复制的设置", + "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({_1})", - "Preview only — {}": "仅预览 — {_1}", - "Profile: {}": "配置文件:{_1}", - "Proof Copy ({})": "校样副本({_1})", - "Proof Preview · {}": "校样预览 · {_1}", + "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 {}": "已评级 {_1}", + "Rated {}": "已评级 {0}", "Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张", - "Redo {}": "重做:{_1}", - "Remove “{}” from Favorites": "从收藏夹中移除“{_1}”", + "Redo {}": "重做:{0}", + "Remove “{}” from Favorites": "从收藏夹中移除“{0}”", "Rename {n} Photos…": "重命名 {n} 张照片…", "Rename “{name}”": "重命名“{name}”", "Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}", - "Reset {} Channel": "重置 {_1} 通道", - "Saved export preset “{}”": "已保存导出预设“{_1}”", - "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置", - "Showing {n} of {}": "显示 {_1} 张中的 {n} 张", - "Smart album: {}": "智能相册:{_1}", - "Undo {}": "撤销 {_1}", - "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {_1} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。", - "Version: {}": "版本:{_1}", + "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": "视图中的 {_1} 张照片", + "the {} photos in view": "视图中的 {0} 张照片", "these {count} photos": "这 {count} 张照片", - "{count} smart preview{} · {:.1} MB": "{count} 个智能预览 · {_1} MB", + "{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}:第 {_1} 个污点,共 {_2} 个", + "{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 会自动重试;现在退出将丢失这些更改。", + "{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": "{_1} 中的 {n} 张照片被上方的过滤器隐藏", - "{n} photo{}": "{n} 张照片", - "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新", - "{n} photo{} now · updates automatically as photos change": "当前 {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}… %", + "{stage}… {:.0}%": "{stage}… {0:.0}%", "{what} merge added": "已添加 {what} 合并", "{what} merge failed: {e}": "{what} 合并失败:{e}", - "{} and higher": "{_1} 及以上", - "{} files": "{_1} 个文件", - "{} files and {f} folder{}": "{_1} 个文件和 {f} 个文件夹", - "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {_1} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。", - "{} photos": "{_1} 张照片", - "{} photo{} not readable": "{_1} 张照片无法读取", - "{} spot(s) on this photo": "此照片上有 {_1} 处污点", + "{} 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} 张照片", + "· {n} photo{}": "· {n} 张照片{0:.0}", "… and {more} more": "…以及另外 {more} 项", - "Import cancelled · {} photo{} added": "导入已取消 · 已添加 {_1} 张照片", - "Reading photos… {} of {}": "正在读取照片… 第 {_1} 张,共 {_2} 张", - "Adding photos… {} of {}": "正在添加照片… 第 {_1} 张,共 {_2} 张" + "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} · 右键单击 ▸ 恢复,或永久删除后重新导入" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 3ebd08d2..5cac9dfe 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1017,5 +1017,7 @@ "Quit": "退出", "Temporary session — your library isn't open, and nothing you do here is saved.": "临时会话 — 图库未打开,此处所做的任何操作都不会保存。", "Back Up Library…": "备份图库…", - "Restore Library from Backup…": "从备份恢复图库…" + "Restore Library from Backup…": "从备份恢复图库…", + "In Recently Deleted": "在“最近删除”中", + "Reload from Disk": "从磁盘重新读取" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 55f52d52..92ac47ee 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -185,44 +185,8 @@ fn catalog_verbatim(language: Locale) -> BTreeMap { language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() } -/// Whether the language translates anything at all (the `tr_format!` fast path). -pub fn has_translations(language: Locale) -> bool { - !language.catalog().is_empty() -} - -/// Translate a format string with runtime values, as `tr_format!` calls it. -/// -/// `english` is the source text already formatted with the call site's values, and `values` names -/// those values so the translation can place them. A message with no translation, or a translation -/// without placeholders, keeps `english` as it is. -pub fn format_message(source: &str, english: String, values: &[(&str, String)]) -> String { - let Some(translated) = language().translated_format(source) else { - return english; - }; - if !translated.contains('{') { - return translated.to_string(); - } - let mut out = String::with_capacity(translated.len()); - let mut rest = translated; - while let Some(start) = rest.find('{') { - let Some(end) = rest[start..].find('}') else { break }; - let field = &rest[start + 1..start + end]; - let (name, _) = field.split_once(':').unwrap_or((field, "")); - out.push_str(&rest[..start]); - // The catalogs spell every value out by name; anything else (a `{}` that only the English - // text used, or a name no call site passes) is left exactly as the translator wrote it. - match values.iter().find(|(key, _)| *key == name) { - Some((_, value)) => out.push_str(value), - None => out.push_str(&rest[start..start + end + 1]), - } - rest = &rest[start + end + 1..]; - } - out.push_str(rest); - out -} - -// `tr_format!` (one arm per language) and the translated-format lookup are generated by -// `build.rs`; `Locale` is declared above, so the generated `impl Locale` has it in scope. +// `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)] @@ -311,8 +275,7 @@ mod tests { /// falls back to the English format rather than dropping the values. #[test] fn formats_render_in_every_language() { - // The English source is what `format!` checks at the call site; each translation then places - // the same values by name (`{n}`, `{total}`) or by position (`{_1}`, `{_2}`). + // 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"); @@ -322,10 +285,21 @@ mod tests { 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枚を書き出しました"); + // 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); - // A format no catalog carries keeps its values, and one with no values keeps its text. - assert_eq!(tr_format!("{n} uncatalogued {thing}", n = 3, thing = "things"), "3 uncatalogued things"); - assert_eq!(tr_format!("Activity"), "Activity"); + 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 diff --git a/docs/localization-ja.md b/docs/localization-ja.md index 9dfa998e..bcfb8a8d 100644 --- a/docs/localization-ja.md +++ b/docs/localization-ja.md @@ -19,10 +19,9 @@ LightCraft は日本語・簡体字中国語・英語(English)の表示に 静的な表示文言は `crates/ui-egui/locales/ja.json`、可変値を含む表示文言は `crates/ui-egui/locales/ja-formats.json` にあります。英文をキーにして翻訳を管理します。 -可変文言は、呼び出し側の英語の書式を `format!` がそのまま検査し、その値を翻訳側が -名前(`{n}`、`{total}`)または位置(`_1`、`_2` …)で参照します。英語の単複数語尾のように -中国語・日本語で不要な値は、**単に参照しないだけ**です(`{:.0}` のような空の引数は不要)。 -両言語の訳文はビルド時に `format!` の検査を受けるため、存在しない名前を書くとコンパイルエラーになります。 +可変文言の訳文はすべてビルド時にRustの `format!` 検査を受けます。値は名前(`{n}`)または +位置(`{0}`、`{1}`)で参照し、`{:.1}` などの書式指定は英文と同じものを使います。 +英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。 `LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。 表示・フォント・言語の切り替え・設定の保存・コマンドIDの保持は diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index f643c2aa..2da811bb 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -22,13 +22,13 @@ LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 含数值的文字遵循以下约定: -- 调用点的英文原文由 `format!` 检查(占位符写错会编译失败),翻译再按名字或位置引用同一个值。 +- 每个译文都是普通的 Rust 格式字符串,构建时由 `format!` 对照调用点的参数检查(占位符写错会编译失败)。 - **按名字**:`{n}`、`{label}`、`{total}` 等与英文原文中的名字一致。 -- **按位置**:`_1`、`_2`、`_3` … 对应调用点参数的先后顺序(从 1 开始),用于调整语序, - 例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{_2}”`。 -- 英文用来表示单复数的那个参数(`"s"` / `""`)在中文里**不需要写**,直接不引用即可; - 不需要 `{:.0}` 之类的空占位符。 -- 译文里的占位符同样会被 `format!` 检查:写了不存在的名字就编译不过。 +- **按位置**:`{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 ...` 可以渲染中文界面用于检查。 diff --git a/docs/localization.md b/docs/localization.md index da3ff77d..b846df7a 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -38,21 +38,22 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md) and [`localization-j 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`. It formats the **English source at the call -site**, which is what gives `format!` a literal to check against that call site's own arguments, and -it also names those values so a translation can place them: +`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 refers to a value **by name** exactly as the English source spells it (`{n}`), or - **by position** as `_1`, `_2`, … in call-site order. Reordering is fine: - `"已将 {n} 张照片添加到“{_2}”"`. -- The English plural suffix (`"s"` / `""`) is simply not referenced in languages without plurals. -- Every translation is placeholder-checked at compile time: an unknown name, or a spec that does not - match its value, fails the build rather than printing `{}` at runtime. -- A source string with no translation falls back to the English text, values included. +- 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 From 87a58b4183839ebcc4874314027b534a5841f2bc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:17:06 -0400 Subject: [PATCH 3/4] LR-BEHAV-LOCALIZE: one Language menu entry per language; parity reads the language table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UI_COMMANDS still listed app.language.english/japanese next to the new LANGUAGE_COMMANDS table, so Edit > Language showed English and 日本語 twice - LANGUAGE_COMMANDS is `pub const`, the form xtask parity scans for; a test reads the real menus.rs and checks every language id is found once - the Simplified Chinese command is app.language.simplifiedChinese, in line with the other camelCase command ids (the settings code stays zh-hans) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 2 +- crates/ui-egui/src/menus.rs | 8 +++----- docs/localization.md | 2 +- docs/parity.md | 2 +- xtask/src/parity.rs | 13 +++++++++++++ 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 92ac47ee..5a7e0f30 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -366,7 +366,7 @@ mod tests { /// 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.zh-hans", Locale::ZhHans), ("app.language.japanese", Locale::Ja)]; + 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 { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92910324..9f63e108 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -12,9 +12,9 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static /// 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. -const LANGUAGE_COMMANDS: &[UiCommand] = &[ +pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), - ("app.language.zh-hans", crate::i18n::Locale::ZhHans.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"), ]; @@ -29,15 +29,13 @@ pub fn ui_commands() -> impl Iterator { pub fn language_from_command(id: &str) -> Option { match id { "app.language.english" => Some(crate::i18n::Locale::En), - "app.language.zh-hans" => Some(crate::i18n::Locale::ZhHans), + "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) diff --git a/docs/localization.md b/docs/localization.md index b846df7a..3eb5efca 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -70,7 +70,7 @@ LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot --demo --script tour.jsonl - ``` or, with the app running (`--control 7980`), run the language command -(`engine.execute {"command": "app.language.zh-hans"}`) and take a screenshot. +(`engine.execute {"command": "app.language.simplifiedChinese"}`) and take a screenshot. ## Language codes and settings diff --git a/docs/parity.md b/docs/parity.md index d81f60f9..fc922678 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -503,7 +503,7 @@ 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-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.zh-hans`, `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-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 | diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 3e59a201..321e3413 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -442,5 +442,18 @@ 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"); } } From 269934caca4e8a81200341e1512d963d1f4c2e21 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:19:50 -0400 Subject: [PATCH 4/4] LR-BEHAV-LOCALIZE: reinstall fonts on a language switch; restore the painted-UI tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the CJK fallback order follows the UI language's script, but the fonts were only installed on the first frame: switching 日本語 ↔ 简体中文 at runtime kept the previous language's Han forms. The app remembers the language its fonts were built for and reinstalls them when it changes. - the native menu bar rebuilds on any language change (keyed on the language code, not on "uses CJK fonts", which is true for both CJK languages) - restore the tests the language-table change dropped, for every language: core catalog entries, user-named menu items stay untranslated, the window paints each language and menu ids don't change with it, CJK glyphs in every UI family, and the UI runs without craft-fonts Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/native_menu.rs | 2 +- crates/ui-egui/src/i18n.rs | 156 +++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 10 ++ xtask/src/parity.rs | 3 +- 4 files changed, 162 insertions(+), 9 deletions(-) diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 33b5c867..d3c9bdb7 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec)]) -> String { out.push(';'); } } - let mut s = format!("{};", lightcraft_ui_egui::i18n::uses_cjk_fonts()); + let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code()); for (t, items) in bar { s.push_str(t); walk(items, &mut s); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5a7e0f30..c208ecb6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -153,12 +153,6 @@ pub fn language() -> Locale { LOCALE.with(std::cell::Cell::get) } -/// Whether the active language needs the craft-fonts CJK faces (Inter alone has no CJK glyphs): -/// the native menu bar uses it to pick a text style. -pub fn uses_cjk_fonts() -> bool { - language().script() != "Latn" -} - /// 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 { @@ -366,7 +360,8 @@ mod tests { /// 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)]; + 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 { @@ -389,4 +384,151 @@ mod tests { let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); assert_eq!(restored.language, Locale::ZhHans); } + + #[test] + 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) => { + text.push_str(&shape.galley.job.text); + text.push('\n'); + } + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + 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 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); + } + } + 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::>(); + 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(); + ctx.set_fonts(crate::theme::font_definitions(craft)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + ctx + } + + /// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI + /// family, Monospace included. + #[test] + 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 every language) still installs its fonts and runs; + /// Latin text keeps Inter. + #[test] + fn the_ui_works_without_craft_fonts() { + let ctx = fonts_ctx(&[]); + ctx.fonts_mut(|fonts| { + // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's + // replacement-glyph face, which there is Hack, the face that draws Latin.) + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); + } + }); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + 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(Locale::En); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 90b72967..5fb0bd7b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; } diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 321e3413..fc87ecc3 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -442,7 +442,8 @@ 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"; + 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"]); }