From 2c94b7fa1f4de14522993110ad4286bb952ce2b4 Mon Sep 17 00:00:00 2001
From: storybored379
Made by the ArtCraft team and community.
-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 e7454f249..403d3d3d0 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 c1cc9d8a7..c6e0a42e6 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 8af250a5b..33b5c8676 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 ab0d8edf0..6affb8b4d 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 954d92631..c028ba53e 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 75c3e03c9..600997fdd 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 9da942f87..467ebba80 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 000000000..a1e9f86c2
--- /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 000000000..3ebd08d20
--- /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 9e1f09252..d38980654 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 0e2389a44..55f52d523 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 8addc61b5..9f6889199 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 f122041e0..9352a9c1b 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 5d838b759..92910324d 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 fe8b04781..1ea18d0ed 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 a380090e1..e191668d9 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 07665dad0..c5d2ce16a 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 1a4446e3a..f9d8c921a 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 b5db692d5..c77fb0a2f 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 4d1aa6277..9dfa998e8 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 000000000..f643c2aa8
--- /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 000000000..da3ff77df
--- /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 fc94d08a7..d1514c9f5 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 caa3932a9..3e59a2013 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 489f78ba6967067ad68f6471b39a6708f1a50213 Mon Sep 17 00:00:00 2001
From: Mohammed Munzir
Date: Wed, 7 Oct 2026 10:40:55 +0300
Subject: [PATCH 19/30] CLI: the connect hint names the port it actually tried
(#167)
run --connect ADDR could not reach the app and always advised lightcraft --control 7980, whatever port ADDR named, so following the hint left the command connecting elsewhere. The hint now takes the port from ADDR itself, with an end-to-end test on a refused connection.
---
apps/lightcraft-cli/src/main.rs | 68 +++++++++++++++++---------------
apps/lightcraft-cli/tests/cli.rs | 10 +++++
2 files changed, 47 insertions(+), 31 deletions(-)
diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs
index 9bf0e3015..c1312081d 100644
--- a/apps/lightcraft-cli/src/main.rs
+++ b/apps/lightcraft-cli/src/main.rs
@@ -416,39 +416,39 @@ fn run(args: &[String]) -> Result<(), String> {
if steps.is_empty() {
return Err("run: no command given (see `lightcraft-cli commands`)".into());
}
- let mut backend: Box = match connect {
- Some(addr) => {
- if demo || library.is_some() || !imports.is_empty() {
- return Err("--demo, --library and --import apply to headless mode only".into());
- }
- Box::new(
- Remote::connect(&addr)
- .map_err(|e| format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control 7980`"))?,
- )
- }
- None => {
- let mut h = match &library {
- Some(dir) => {
- let mut h = Headless::default();
- h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
- h
- }
- None if demo => Headless::demo(),
- None => Headless::default(),
- };
- if !imports.is_empty() {
- let paths = expand_paths(&imports);
- let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
- // what follows acts on the imported photos (already-known files are reported as duplicates)
- let mut ids: Vec = r["imported"].as_array().cloned().unwrap_or_default();
- ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
- if let Some(first) = ids.first().cloned() {
- h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
+ let mut backend: Box =
+ match connect {
+ Some(addr) => {
+ if demo || library.is_some() || !imports.is_empty() {
+ return Err("--demo, --library and --import apply to headless mode only".into());
}
+ Box::new(Remote::connect(&addr).map_err(|e| {
+ format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr))
+ })?)
}
- Box::new(h)
- }
- };
+ None => {
+ let mut h = match &library {
+ Some(dir) => {
+ let mut h = Headless::default();
+ h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
+ h
+ }
+ None if demo => Headless::demo(),
+ None => Headless::default(),
+ };
+ if !imports.is_empty() {
+ let paths = expand_paths(&imports);
+ let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
+ // what follows acts on the imported photos (already-known files are reported as duplicates)
+ let mut ids: Vec = r["imported"].as_array().cloned().unwrap_or_default();
+ ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
+ if let Some(first) = ids.first().cloned() {
+ h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
+ }
+ }
+ Box::new(h)
+ }
+ };
let mut out = std::io::stdout().lock();
let mut failed = 0;
for s in &steps {
@@ -474,6 +474,12 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s
args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value"))
}
+/// The port an `--connect` address names, for the recovery hint. The whole address when it has
+/// no port, so a hint is never built from a guess.
+fn connect_port(addr: &str) -> &str {
+ addr.rsplit_once(':').map_or(addr, |(_, port)| port)
+}
+
fn render(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut output = None;
diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs
index 9d41873a4..37799f4aa 100644
--- a/apps/lightcraft-cli/tests/cli.rs
+++ b/apps/lightcraft-cli/tests/cli.rs
@@ -324,3 +324,13 @@ fn a_library_open_in_another_process_is_refused() {
assert_eq!(lines[0]["ok"], true);
let _ = std::fs::remove_dir_all(&lib);
}
+
+/// Issue #167: when `--connect ADDR` cannot reach the app, the recovery hint must name the port
+/// ADDR actually names — following the old fixed "7980" left the retry connecting elsewhere.
+#[test]
+fn connect_failure_hint_names_the_attempted_port() {
+ let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
+ assert!(!ok);
+ assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}");
+ assert!(!err.contains("--control 7980"), "{err}");
+}
From db640ed1d39c0b53e0f79a45c11d10cf6a1e2f77 Mon Sep 17 00:00:00 2001
From: sakamoto824
Date: Wed, 7 Oct 2026 17:32:12 +0800
Subject: [PATCH 20/30] M16: add Traditional Chinese UI, menus and stock preset
localization
Co-authored-by: Codex
---
README.md | 8 +-
ROADMAP.md | 12 +-
apps/lightcraft/src/native_menu.rs | 19 +-
crates/ui-egui/build.rs | 16 +-
crates/ui-egui/locales/ja-formats.json | 23 +-
crates/ui-egui/locales/zh-tw-formats.json | 119 ++
crates/ui-egui/locales/zh-tw.json | 1247 +++++++++++++++++++++
crates/ui-egui/src/control.rs | 1 +
crates/ui-egui/src/i18n.rs | 266 ++++-
crates/ui-egui/src/menubar.rs | 43 +-
crates/ui-egui/src/menus.rs | 14 +-
crates/ui-egui/src/panels/dialogs.rs | 10 +-
crates/ui-egui/src/panels/edit.rs | 21 +-
crates/ui-egui/src/panels/grid.rs | 14 +-
crates/ui-egui/src/panels/left.rs | 8 +-
crates/ui-egui/src/panels/presets.rs | 25 +-
crates/ui-egui/src/panels/profiles.rs | 12 +-
crates/ui-egui/src/panels/right.rs | 26 +-
crates/ui-egui/src/panels/settings.rs | 2 +-
docs/localization-zh-tw.md | 46 +
docs/parity.md | 2 +-
21 files changed, 1845 insertions(+), 89 deletions(-)
create mode 100644 crates/ui-egui/locales/zh-tw-formats.json
create mode 100644 crates/ui-egui/locales/zh-tw.json
create mode 100644 docs/localization-zh-tw.md
diff --git a/README.md b/README.md
index e7454f249..76cf14bc1 100644
--- a/README.md
+++ b/README.md
@@ -334,16 +334,20 @@ 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):
+**Japanese and Traditional Chinese interface text** needs 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 CJK interface text has no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
+**Interface language:** choose **Edit → Language → 繁體中文(台灣)**, or **Settings → General → Language**.
+The choice applies immediately and persists between launches, including native macOS menus and the Keyboard Shortcuts sheet.
+See [繁體中文介面說明](docs/localization-zh-tw.md) for build, coverage and control-channel details.
+
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
diff --git a/ROADMAP.md b/ROADMAP.md
index c1cc9d8a7..f8adba02e 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; English/Japanese/Traditional Chinese UI (see docs/localization-ja.md and docs/localization-zh-tw.md); remaining technical errors and other languages; 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 ✅; English/Japanese/Traditional Chinese localisation 🟡; accessibility and other locales ⬜) |
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
@@ -202,3 +202,11 @@ optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed.
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
OpenType shaping. The same coverage renderer serves 8/16/32-bit exports.
+
+## Traditional Chinese (Taiwan) interface
+
+The persisted `zh-tw` language is available in Edit → Language and Settings → General → Language.
+Static and compile-checked dynamic catalogs cover the existing Japanese UI surface, registered command labels,
+native/in-window menus and the keyboard reference. Native macOS menus rebuild when changing locale.
+Technical errors and release notes retain English fallback; full web glyph coverage remains unverified.
+See [docs/localization-zh-tw.md](docs/localization-zh-tw.md).
diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs
index 8af250a5b..2ddd62ad4 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::language());
for (t, items) in bar {
s.push_str(t);
walk(items, &mut s);
@@ -419,6 +419,23 @@ impl NativeMenu {
mod tests {
use super::*;
+ #[test]
+ fn switching_from_english_to_chinese_rebuilds_native_menu_structure() {
+ use lightcraft_ui_egui::i18n::{Language, set_language};
+ let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
+ set_language(Language::En);
+ let bar = menu_bar(&app);
+ let english = structure_of(&bar);
+ set_language(Language::ZhTw);
+ // Titles in the model remain English; the locale must force the native titles
+ // and the macOS app menu to be rebuilt even when no command ids changed.
+ assert_ne!(english, structure_of(&bar));
+ assert_eq!(label_text("File"), "檔案");
+ assert_eq!(label_text("Settings…"), "設定…");
+ assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
+ set_language(Language::En);
+ }
+
#[test]
fn shortcuts_map_to_accelerators() {
assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)));
diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs
index 75c3e03c9..86b8f01fb 100644
--- a/crates/ui-egui/build.rs
+++ b/crates/ui-egui/build.rs
@@ -1,18 +1,24 @@
-//! Compile translated format strings so Rust checks both languages' placeholders.
+//! Compile translated format strings so Rust checks every language's placeholders.
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")?)?;
+ println!("cargo:rerun-if-changed=locales/zh-tw-formats.json");
+ let japanese: BTreeMap = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?;
+ let chinese: BTreeMap = serde_json::from_str(&fs::read_to_string("locales/zh-tw-formats.json")?)?;
+ if !japanese.keys().eq(chinese.keys()) {
+ return Err("Format catalogs must contain the same English keys".into());
+ }
let mut source = String::from("macro_rules! tr_format {\n");
- for (english, japanese) in messages {
+ for (english, japanese) in japanese {
let en = serde_json::to_string(&english)?;
let ja = serde_json::to_string(&japanese)?;
+ let zh = serde_json::to_string(chinese.get(&english).ok_or("Missing Chinese format")?)?;
source.push_str(&format!(
- "({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n"
+ "({en} $(, $($args:tt)*)?) => {{ match $crate::i18n::language() {{ $crate::i18n::Language::Ja => format!({ja} $(, $($args)*)?), $crate::i18n::Language::ZhTw => format!({zh} $(, $($args)*)?), $crate::i18n::Language::En => 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)?;
+ fs::write(PathBuf::from(env::var("OUT_DIR")?).join("formats.rs"), source)?;
Ok(())
}
diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json
index 9da942f87..9e3515464 100644
--- a/crates/ui-egui/locales/ja-formats.json
+++ b/crates/ui-egui/locales/ja-formats.json
@@ -10,9 +10,14 @@
"Add “{}” to Favorites": "「{}」をお気に入りに追加",
"Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}",
"Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}",
+ "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚",
"All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします",
+ "Auto ({})": "自動 ({})",
"Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用",
"Automatic ({} MB)": "自動({}MB)",
+ "Build 1:1 Previews ({scope})": "1:1プレビューを作成({scope})",
+ "Build Smart Previews ({scope})": "スマートプレビューを作成({scope})",
+ "Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})",
"Click {}:1": "クリック時 {}:1",
"Create {n} Virtual Copies": "{n}枚の仮想コピーを作成",
"Decoder: {reason}": "デコーダー:{reason}",
@@ -20,6 +25,7 @@
"Delete \"{label}\" (and the mask)": "「{label}」とマスクを削除",
"Delete {n} Photos": "{n}枚の写真を削除",
"Delete “{current}”": "「{current}」を削除",
+ "Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})",
"Duplicate \"{label}\"": "「{label}」を複製",
"Example: {example}": "例:{example}",
"Exported {n} photo{}": "{n}枚を書き出しました{:.0}",
@@ -29,10 +35,12 @@
"Exporting {} of {total}": "書き出し中:{total}枚中{}枚",
"Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中{:.0}",
"Healed {n} dust spot{}": "{n}か所のほこりを修復しました{:.0}",
+ "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.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}",
+ "Label Set: {name}": "ラベルセット:{name}",
"Matches: {}": "一致条件:{}",
"Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%",
"Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?",
@@ -45,19 +53,23 @@
"Proof Copy ({})": "校正用コピー({})",
"Proof Preview · {}": "校正プレビュー・{}",
"Quick Develop · {n} photos": "クイック現像・{n}枚",
+ "Quick Develop: {name}": "クイック現像: {name}",
"Rated {}": "評価:{}",
"Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚",
+ "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚",
"Redo {}": "やり直す:{}",
"Remove “{}” from Favorites": "「{}」をお気に入りから削除",
"Rename {n} Photos…": "{n}枚の写真の名前を変更…",
"Rename “{name}”": "「{name}」の名前を変更",
"Rendering on the CPU: {why}": "CPUで描画:{why}",
+ "Reset {name}": "{name}をリセット",
"Reset {} Channel": "{}チャンネルをリセット",
"Saved export preset “{}”": "書き出しプリセット「{}」を保存しました",
"Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}",
"Showing {n} of {}": "{}枚中{n}枚を表示",
"Smart album: {}": "スマートアルバム:{}",
"Undo {}": "取り消す:{}",
+ "Updated “{name}”": "「{name}」を更新しました",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{} — Rust製の写真管理・RAW現像アプリ",
"Version: {}": "バージョン:{}",
"none (CPU rendering): {why}": "なし(CPU描画):{why}",
@@ -72,6 +84,8 @@
"{matching} of {t} photos": "{t}枚中{matching}枚",
"{matching} photos": "{matching}枚",
"{mode} spot {} of {}": "{mode}:{1}か所中{0}か所",
+ "{month}": "{month}",
+ "{month} {year}": "{month} {year}",
"{msg} ({failed} failed)": "{msg}({failed}件失敗)",
"{name} Label": "{name}のラベル",
"{name} Off": "{name}を無効にする",
@@ -86,8 +100,12 @@
"{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新",
"{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}",
"{stage}… {:.0}%": "{stage}… {:.0}%",
+ "{verb} {n} Photo{}": "{n}枚を{verb}{:.0}",
+ "{weekday}, {day}": "{weekday}, {day}",
+ "{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}",
"{what} merge added": "{what}合成を追加しました",
"{what} merge failed: {e}": "{what}合成に失敗:{e}",
+ "{year}": "{year}",
"{} and higher": "{}以上",
"{} files": "{}個のファイル",
"{} files and {f} folder{}": "{}個のファイルと{f}個のフォルダ{:.0}",
@@ -97,8 +115,5 @@
"{} spot(s) on this photo": "この写真に{}か所のスポット",
"· {dups} already in the library": "・{dups}枚は読み込み済み",
"· {n} photo{}": "・{n}枚{:.0}",
- "… and {more} more": "…ほか{more}件",
- "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}",
- "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚",
- "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚"
+ "… and {more} more": "…ほか{more}件"
}
diff --git a/crates/ui-egui/locales/zh-tw-formats.json b/crates/ui-egui/locales/zh-tw-formats.json
new file mode 100644
index 000000000..54a0b5193
--- /dev/null
+++ b/crates/ui-egui/locales/zh-tw-formats.json
@@ -0,0 +1,119 @@
+{
+ " · {skipped} skipped (file exists)": " · 已略過 {skipped} 個(檔案已存在)",
+ " · {} duplicate{} skipped": " · 已略過 {} 張重複照片{:.0}",
+ " · {} failed: {}": " · {} 個失敗:{}",
+ " · {} moved": " · 已移動 {} 個",
+ " · {} not readable": " · {} 個無法讀取",
+ ", {failed} file{} not readable": ",{failed} 個檔案無法讀取{:.0}",
+ "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 “{}”": "已將 {n} 張照片加入「{1}」{0:.0}",
+ "Adding photos… {} of {}": "正在新增照片… {}/{}",
+ "All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。",
+ "Auto ({})": "自動({})",
+ "Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片",
+ "Automatic ({} MB)": "自動({} MB)",
+ "Build 1:1 Previews ({scope})": "建立 1:1 預覽({scope})",
+ "Build Smart Previews ({scope})": "建立智慧型預覽({scope})",
+ "Build Standard-Sized Previews ({scope})": "建立標準尺寸預覽({scope})",
+ "Click {}: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}」",
+ "Discard Smart Previews ({scope})": "捨棄智慧型預覽({scope})",
+ "Duplicate \"{label}\"": "複製「{label}」",
+ "Example: {example}": "範例:{example}",
+ "Exported {n} photo{}": "已匯出 {n} 張照片{:.0}",
+ "Exported {ok} of {total} photo{}": "已匯出 {ok}/{total} 張照片{:.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}",
+ "Import cancelled · {} photo{} added": "已取消匯入 · 已加入 {} 張照片{:.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}",
+ "Label Set: {name}": "標籤集:{name}",
+ "Matches: {}": "符合條件:{}",
+ "Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%",
+ "Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?",
+ "Named ({named_n})": "已命名({named_n})",
+ "Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定",
+ "Preset: {name}": "預設集:{name}",
+ "Preview only — editing the camera's embedded JPEG ({})": "僅供預覽 — 正在編輯相機內嵌的 JPEG({})",
+ "Preview only — {}": "僅供預覽 — {}",
+ "Profile: {}": "描述檔:{}",
+ "Proof Copy ({})": "校樣副本({})",
+ "Proof Preview · {}": "校樣預覽 · {}",
+ "Quick Develop · {n} photos": "快速顯像 · {n} 張照片",
+ "Quick Develop: {name}": "快速顯像:{name}",
+ "Rated {}": "評分:{}",
+ "Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}",
+ "Reading photos… {} of {}": "正在讀取照片… {}/{}",
+ "Redo {}": "重做 {}",
+ "Remove “{}” from Favorites": "將「{}」從喜好項目移除",
+ "Rename {n} Photos…": "重新命名 {n} 張照片…",
+ "Rename “{name}”": "重新命名「{name}」",
+ "Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
+ "Reset {name}": "重設{name}",
+ "Reset {} Channel": "重設 {} 通道",
+ "Saved export preset “{}”": "已儲存匯出預設集「{}」",
+ "Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
+ "Showing {n} of {}": "顯示 {n}/{} 張",
+ "Smart album: {}": "智慧型相簿:{}",
+ "Undo {}": "復原 {}",
+ "Updated “{name}”": "已更新「{name}」",
+ "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {} — 以純 Rust 獨立實作的照片管理與 RAW 顯像程式。",
+ "Version: {}": "版本:{}",
+ "none (CPU rendering): {why}": "無(使用 CPU 算繪):{why}",
+ "the {n} selected photos": "選取的 {n} 張照片",
+ "the {} photos in view": "目前顯示的 {} 張照片",
+ "these {count} photos": "這 {count} 張照片",
+ "{count} smart preview{} · {:.1} MB": "{count} 張智慧型預覽{:.0} · {:.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": "{matching}/{t} 張照片",
+ "{matching} photos": "{matching} 張照片",
+ "{mode} spot {} of {}": "{mode}斑點:第 {}/{} 個",
+ "{month}": "{month}月",
+ "{month} {year}": "{year}年{month}月",
+ "{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}\nLightCraft 會自動重試;現在結束程式會遺失{}。",
+ "{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} · 隨照片變更自動更新",
+ "{sel_n} selected · {counted}": "已選取 {sel_n} 張 · {counted}",
+ "{stage}… {:.0}%": "{stage}… {:.0}%",
+ "{verb} {n} Photo{}": "{verb} {n} 張照片{:.0}",
+ "{weekday}, {day}": "{day}日 {weekday}",
+ "{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}",
+ "{what} merge added": "已新增{what}合併照片",
+ "{what} merge failed: {e}": "{what}合併失敗:{e}",
+ "{year}": "{year}年",
+ "{} 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": "此照片有 {} 個斑點",
+ "· {dups} already in the library": "· {dups} 張已在照片庫中",
+ "· {n} photo{}": "· {n} 張照片{:.0}",
+ "… and {more} more": "…以及另外 {more} 個"
+}
diff --git a/crates/ui-egui/locales/zh-tw.json b/crates/ui-egui/locales/zh-tw.json
new file mode 100644
index 000000000..c56b40fb6
--- /dev/null
+++ b/crates/ui-egui/locales/zh-tw.json
@@ -0,0 +1,1247 @@
+{
+ "+ 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": "處理狀態",
+ "Actual size": "實際大小",
+ "Add": "新增",
+ "Add Catchlight": "新增眼神光",
+ "Add Folder to Local": "將資料夾加入本機",
+ "Add Point Color Sample": "新增點色彩樣本",
+ "Add Red Eye Correction": "新增紅眼校正",
+ "Add Remove Spot": "新增移除斑點",
+ "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 to Target Album": "加入目標相簿",
+ "Add/Subtract/Intersect Mask": "新增/減去/交集遮色片",
+ "Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。",
+ "Adding": "正在新增",
+ "Advanced search…": "進階搜尋…",
+ "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 Color Label Set": "套用色彩標籤集",
+ "Apply Filter Preset": "套用篩選預設集",
+ "Apply Metadata Preset": "套用中繼資料預設集",
+ "Apply Point Curve Preset": "套用點曲線預設集",
+ "Apply Preset": "套用預設集",
+ "Apply Settings JSON": "套用 JSON 設定",
+ "Approved": "已核准",
+ "Aqua": "青色",
+ "Aqua Hue": "青色色相",
+ "Aqua Luminance": "青色明度",
+ "Aqua Saturation": "青色飽和度",
+ "ArtCraft Website": "ArtCraft 網站",
+ "ArtCraft — more creative apps": "ArtCraft — 更多創作應用程式",
+ "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 B&W Mix": "自動黑白混色",
+ "Auto Crop": "自動裁切",
+ "Auto Import": "自動匯入",
+ "Auto Import Now": "立即自動匯入",
+ "Auto Import Settings": "自動匯入設定",
+ "Auto Mask": "自動遮色片",
+ "Auto Settings": "自動設定",
+ "Auto Straighten": "自動拉直",
+ "Auto Sync": "自動同步",
+ "Auto-Stack by Capture Time": "依拍攝時間自動堆疊",
+ "Auto-Stack by Capture Time…": "依拍攝時間自動堆疊…",
+ "Auto-Tag Photos from Tracklog": "依軌跡記錄自動標記照片位置",
+ "Auto-Tag from Tracklog": "依軌跡記錄自動加入位置標記",
+ "Auto-Tag from Tracklog…": "依軌跡記錄自動加入位置標記…",
+ "Automatic": "自動",
+ "Automatically Write Changes into XMP": "自動將變更寫入 XMP",
+ "Automatically write changes into XMP sidecars": "自動將變更寫入 XMP 附屬檔案",
+ "Autumn Gold": "秋日金色",
+ "B&W": "黑白",
+ "B&W Mix": "黑白混色",
+ "B&W Mixer": "黑白混色器",
+ "Back": "返回",
+ "Back Up Library…": "備份照片庫…",
+ "Back to Edit": "返回編輯",
+ "Back to Edit (E)": "返回編輯(E)",
+ "Back to Grid": "返回格狀檢視",
+ "Background": "背景",
+ "Balance": "平衡",
+ "Basic": "基本",
+ "Before": "編輯前",
+ "Before/After (Y)": "編輯前/後(Y)",
+ "Before/After Settings": "編輯前/後設定",
+ "Before/After Split": "編輯前/後分割檢視",
+ "Before/After Split Top/Bottom": "編輯前/後上下分割檢視",
+ "Before/After Top/Bottom": "編輯前/後上下檢視",
+ "Begin Interaction": "開始互動",
+ "Bit depth": "位元深度",
+ "Black & White": "黑白",
+ "Blacks": "黑色色階",
+ "Bleach Bypass": "漂白略過",
+ "Bleached": "漂白效果",
+ "Blending": "混合",
+ "Blue": "藍色",
+ "Blue Hour Boost": "藍調時刻增強",
+ "Blue Hue": "藍色色相",
+ "Blue Luminance": "藍色明度",
+ "Blue Primary": "藍色原色",
+ "Blue Saturation": "藍色飽和度",
+ "Blue channel": "藍色通道",
+ "Blue/Yellow Fringe": "藍/黃色邊緣",
+ "Blurry photos below the score are flagged as rejects.": "焦點分數低於門檻的模糊照片會標記為排除。",
+ "Boundary Warp": "邊界變形",
+ "Bright & Airy": "明亮輕盈",
+ "Bring All to Front": "將所有視窗移至最前方",
+ "Browse Folder": "瀏覽資料夾",
+ "Browse Folder…": "瀏覽資料夾…",
+ "Browse Profiles": "瀏覽描述檔",
+ "Browse…": "瀏覽…",
+ "Brush": "筆刷",
+ "Brush Settings": "筆刷設定",
+ "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": "取消",
+ "Cancel Interaction": "取消互動",
+ "Cancel Preview Build": "取消建立預覽",
+ "Candidate": "候選照片",
+ "Caption": "圖說",
+ "Capture Date": "拍攝日期",
+ "Captured": "拍攝時間",
+ "Catalog Statistics": "目錄統計",
+ "Check All": "全部勾選",
+ "Check Output Path": "檢驗輸出路徑",
+ "Choose Another Library…": "選擇其他照片庫…",
+ "Choose Edit Settings to Copy": "選擇要複製的編輯設定",
+ "Choose Edit Settings to Copy…": "選擇要複製的編輯設定…",
+ "Choose Folder…": "選擇資料夾…",
+ "Choose a photo in the grid or filmstrip": "請在格狀檢視或底片顯示窗格中選擇照片",
+ "Choose…": "選擇…",
+ "Cinematic": "電影風格",
+ "City": "城市",
+ "Clarity": "清晰度",
+ "Clean Studio": "純淨棚拍",
+ "Clear": "清除",
+ "Clear Cache": "清除快取",
+ "Clear Filters": "清除篩選條件",
+ "Clear Guides": "清除參考線",
+ "Clear History": "清除歷史紀錄",
+ "Clear Preview Cache": "清除預覽快取",
+ "Clear Quick Collection": "清除快速集合",
+ "Clear Reference": "清除參考照片",
+ "Clear all": "全部清除",
+ "Clear form": "清除表單",
+ "Cleared History": "已清除歷史紀錄",
+ "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": "關閉視窗",
+ "Close all": "全部關閉",
+ "Close file": "關閉檔案",
+ "Cloudy": "陰天",
+ "Collapse All Stacks": "收合所有堆疊",
+ "Color": "色彩",
+ "Color Grading": "色彩分級",
+ "Color Label Names": "色彩標籤名稱",
+ "Color Label Sets": "色彩標籤集",
+ "Color Mixer": "混色器",
+ "Color Noise Reduction": "色彩雜訊抑制",
+ "Color Priority": "色彩優先",
+ "Color Range": "色彩範圍",
+ "Color space": "色彩空間",
+ "Color space: sRGB (AVIF)": "色彩空間:sRGB(AVIF)",
+ "Combine files…": "合併檔案…",
+ "Command Journal": "指令記錄",
+ "Comments panel": "註解面板",
+ "Compare": "比較",
+ "Compare (Shift+C)": "比較(Shift+C)",
+ "Compare Before and After": "比較編輯前與編輯後",
+ "Compare needs at least two photos": "比較需要至少兩張照片",
+ "Component options": "元件選項",
+ "Compression": "壓縮",
+ "Concrete Cool": "冷調混凝土",
+ "Confirm before deleting": "刪除前確認",
+ "Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認",
+ "Constrain Crop": "限制裁切範圍",
+ "Continue Without Saving": "繼續使用但不儲存",
+ "Contrast": "對比",
+ "Convert to DNG": "轉換為 DNG",
+ "Cool Fade": "冷調褪色",
+ "Cool Morning": "清冷晨光",
+ "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 pages": "複製頁面",
+ "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": "著作權資訊網址",
+ "Copyright Status": "著作權狀態",
+ "Country": "國家",
+ "Create Album": "建立相簿",
+ "Create Album…": "建立相簿…",
+ "Create Folder": "建立資料夾",
+ "Create Folder…": "建立資料夾…",
+ "Create New Mask": "建立新遮色片",
+ "Create PDF from clipboard": "從剪貼簿建立 PDF",
+ "Create PDF from file…": "從檔案建立 PDF…",
+ "Create PDF from images…": "從圖片建立 PDF…",
+ "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": "創作者",
+ "Crisp Landscape": "清晰風景",
+ "Crisp Vista": "清晰遠景",
+ "Crop": "裁切",
+ "Crop & Rotate": "裁切與旋轉",
+ "Crop & Rotate (C)": "裁切與旋轉(C)",
+ "Crop Aspect": "裁切長寬比",
+ "Cross Process": "交叉沖洗",
+ "Culling": "挑片",
+ "Curve": "曲線",
+ "Custom": "自訂",
+ "Custom…": "自訂…",
+ "Cut": "剪下",
+ "Cut pages": "剪下頁面",
+ "Cycle Crop Overlay": "切換裁切疊加",
+ "Cycle Crop Overlay Orientation": "切換裁切疊加方向",
+ "Cycle Info Overlay": "切換資訊疊加",
+ "Cycle Mask Overlay Color": "切換遮色片疊加色彩",
+ "Cycle Mask Overlay Mode": "切換遮色片疊加模式",
+ "Cylindrical": "圓柱形",
+ "Dark & Moody": "暗調氛圍",
+ "Darken": "變暗",
+ "Darks": "暗部",
+ "Date Groups": "日期群組",
+ "Day": "日",
+ "Daylight": "日光",
+ "Decrease Brush Feather": "減少筆刷羽化",
+ "Decrease Brush Size": "縮小筆刷尺寸",
+ "Deep Sky": "深邃天空",
+ "Defringe": "去除邊緣色差",
+ "Deghost Amount": "去除鬼影強度",
+ "Dehaze": "去朦朧",
+ "Delete": "刪除",
+ "Delete (⌫)": "刪除(⌫)",
+ "Delete Album": "刪除相簿",
+ "Delete All Masks": "刪除所有遮色片",
+ "Delete Color Label Set": "刪除色彩標籤集",
+ "Delete Export Preset": "刪除匯出預設集",
+ "Delete Filter Preset": "刪除篩選預設集",
+ "Delete Imported Profile": "刪除匯入的描述檔",
+ "Delete Keyword": "刪除關鍵字",
+ "Delete Keyword Set": "刪除關鍵字集",
+ "Delete Mask": "刪除遮色片",
+ "Delete Metadata Preset": "刪除中繼資料預設集",
+ "Delete Permanently": "永久刪除",
+ "Delete Photo": "刪除照片",
+ "Delete Photos": "刪除照片",
+ "Delete Point Color Sample": "刪除點色彩樣本",
+ "Delete Point Curve Preset": "刪除點曲線預設集",
+ "Delete Preset": "刪除預設集",
+ "Delete Red Eye Correction": "刪除紅眼校正",
+ "Delete Spot": "刪除斑點",
+ "Delete Version": "刪除版本",
+ "Delete all": "全部刪除",
+ "Delete all spots": "刪除所有斑點",
+ "Delete mask": "刪除遮色片",
+ "Delete them": "刪除這些照片",
+ "Density": "濃度",
+ "Depth Range": "深度範圍",
+ "Descending": "遞減",
+ "Deselect": "取消選取",
+ "Deselect All": "取消全選",
+ "Desert Heat": "沙漠熱浪",
+ "Desert Warmth": "沙漠暖色",
+ "Detail": "詳細檢視",
+ "Detail (D)": "詳細檢視(D)",
+ "Diagonal": "對角線",
+ "Discard Preview Cache": "捨棄預覽快取",
+ "Display P3": "Display P3",
+ "Display theme": "顯示主題",
+ "Distortion": "變形",
+ "Document properties…": "文件內容…",
+ "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 Color Label Names…": "編輯色彩標籤名稱…",
+ "Edit Copy for External Editor": "編輯外部編輯器副本",
+ "Edit Info": "編輯資訊",
+ "Edit Label Names…": "編輯標籤名稱…",
+ "Edit Mask Component": "編輯遮色片元件",
+ "Edit Sections": "編輯區段",
+ "Edit Smart Album": "編輯智慧型相簿",
+ "Edit Smart Album…": "編輯智慧型相簿…",
+ "Edit Spot": "編輯斑點",
+ "Edit in External Editor": "在外部編輯器中編輯",
+ "Edited": "已編輯",
+ "Effect": "效果",
+ "Effects": "效果",
+ "Enable Lens Corrections": "啟用鏡頭校正",
+ "End Interaction": "結束互動",
+ "English": "English",
+ "Enter Full Screen": "進入全螢幕",
+ "Erase": "擦除",
+ "Every EXIF, GPS and XMP field in the file": "檔案中的所有 EXIF、GPS 與 XMP 欄位",
+ "Existing previews": "現有預覽",
+ "Exit": "結束",
+ "Expand All Stacks": "展開所有堆疊",
+ "Expand/Collapse Stack": "展開/收合堆疊",
+ "Export": "匯出",
+ "Export Album with Preset": "使用預設集匯出相簿",
+ "Export Album…": "匯出相簿…",
+ "Export As…": "匯出為…",
+ "Export Group…": "匯出群組…",
+ "Export Now": "立即匯出",
+ "Export Point Curve Presets": "匯出點曲線預設集",
+ "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": "外部編輯器",
+ "Faded Matte": "褪色霧面",
+ "Faded Slide": "褪色正片",
+ "Faded Urban": "褪色城市",
+ "Favorite Preset": "將預設集加入喜好項目",
+ "Favorite Profile": "將描述檔加入喜好項目",
+ "Favorites": "喜好項目",
+ "Feather": "羽化",
+ "File": "檔案",
+ "File & date": "檔案與日期",
+ "File Name": "檔案名稱",
+ "File Path": "檔案路徑",
+ "File Size": "檔案大小",
+ "File name": "檔案名稱",
+ "Fill Edges": "填補邊緣",
+ "Film": "底片",
+ "Filmstrip": "底片顯示窗格",
+ "Filmstrip (/)": "底片列(/)",
+ "Filter": "篩選",
+ "Filter Bar": "篩選列",
+ "Filter Presets": "篩選預設集",
+ "Filter bar": "篩選列",
+ "Filter fields": "篩選欄位",
+ "Find Dust Spots": "尋找灰塵斑點",
+ "Find Missing Photos": "尋找遺失的照片",
+ "Find Missing Photos…": "尋找遺失的照片…",
+ "Find Similar Photos": "尋找相似照片",
+ "Find tools and commands…": "尋找工具與指令…",
+ "Find…": "尋找…",
+ "Fit": "符合視窗",
+ "Fit (F)": "符合視窗(F)",
+ "Fit on Screen": "符合螢幕",
+ "Fit to width": "符合寬度",
+ "Fit visible": "符合可見範圍",
+ "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} 使用拍攝時間;沒有拍攝時間的照片使用今天的日期。",
+ "Food": "美食",
+ "Forget Unchanged Local Photos": "忘記未變更的本機照片",
+ "Forget unchanged photos": "忘記未變更的照片",
+ "Form fields panel": "表單欄位面板",
+ "Format": "格式",
+ "Forward": "前進",
+ "Free": "自由",
+ "Fresh & Bright": "清新明亮",
+ "Friday": "星期五",
+ "Full": "完整",
+ "Full Screen Preview": "全螢幕預覽",
+ "Full screen mode": "全螢幕模式",
+ "Full size": "完整尺寸",
+ "GPS Track Log": "GPS 軌跡記錄",
+ "GPU Rendering": "GPU 算繪",
+ "General": "一般",
+ "Geometry": "幾何",
+ "Get Develop Settings": "取得顯像設定",
+ "Global Hue": "整體色相",
+ "Global Luminance": "整體明度",
+ "Global Saturation": "整體飽和度",
+ "Glossy": "亮面",
+ "Go to History Step": "前往歷史步驟",
+ "Golden": "黃金比例",
+ "Golden Glow": "金色光暈",
+ "Golden Hour": "黃金時刻",
+ "Grain": "顆粒",
+ "Graphic": "圖像",
+ "Green": "綠色",
+ "Green Amount": "綠色強度",
+ "Green Hue": "綠色色相",
+ "Green Hue High": "綠色色相上限",
+ "Green Hue Low": "綠色色相下限",
+ "Green Luminance": "綠色明度",
+ "Green Primary": "綠色原色",
+ "Green Saturation": "綠色飽和度",
+ "Green channel": "綠色通道",
+ "Grid": "格狀檢視",
+ "Grid Info": "格狀檢視資訊",
+ "Gritty Street": "粗礪街頭",
+ "Group": "群組",
+ "Group by Date: Automatic": "依日期分組:自動",
+ "Group by Day": "依日分組",
+ "Group by Month": "依月分組",
+ "Group by Year": "依年分組",
+ "Group by date": "依日期分組",
+ "Group into Stack": "分組為堆疊",
+ "Guided": "引導式",
+ "Guided Upright": "引導式透視校正",
+ "Guided Upright Guides": "引導式透視校正參考線",
+ "HDR Merge": "HDR 合併",
+ "HDR Panorama Merge": "HDR 全景合併",
+ "HDR Panorama with Last Settings": "使用上次設定合併 HDR 全景",
+ "HDR Panorama…": "HDR 全景…",
+ "HDR with Last Settings": "使用上次設定合併 HDR",
+ "HDR…": "HDR…",
+ "Heal": "修復",
+ "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點",
+ "Height": "高度",
+ "Height (px)": "高度(像素)",
+ "Help": "說明",
+ "Hide LightCraft": "隱藏 LightCraft",
+ "Hide Mask": "隱藏遮色片",
+ "Hide Others": "隱藏其他應用程式",
+ "Hides this shortcut only; the folder and its photos stay as they are": "只隱藏此捷徑,資料夾及照片維持原狀",
+ "High": "高",
+ "High Contrast B&W": "高對比黑白",
+ "Highlight": "亮部",
+ "Highlights": "亮部",
+ "Highlights Hue": "亮部色相",
+ "Highlights Luminance": "亮部明度",
+ "Highlights Saturation": "亮部飽和度",
+ "Highlights split": "亮部分界",
+ "Histogram": "色階分佈圖",
+ "History": "歷史紀錄",
+ "History & Activity (Y)": "歷史紀錄與處理狀態(Y)",
+ "Horizontal": "水平",
+ "Horizontal text": "水平文字",
+ "Hue": "色相",
+ "Hue Range": "色相範圍",
+ "If file exists": "檔案已存在時",
+ "Image": "圖片",
+ "Import": "匯入",
+ "Import Date": "匯入日期",
+ "Import Photos": "匯入照片",
+ "Import Photos…": "匯入照片…",
+ "Import Point Curve Presets": "匯入點曲線預設集",
+ "Import Point Curve Presets…": "匯入點曲線預設集…",
+ "Import Presets": "匯入預設集",
+ "Import Presets…": "匯入預設集…",
+ "Import Profiles": "匯入描述檔",
+ "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": "包含子資料夾",
+ "Increase Brush Feather": "增加筆刷羽化",
+ "Increase Brush Size": "放大筆刷尺寸",
+ "Info": "資訊",
+ "Info (I)": "資訊(I)",
+ "Info overlay": "資訊疊加",
+ "Inspect Photo": "檢驗照片",
+ "Instant ’70s": "七〇年代拍立得",
+ "Interface": "介面",
+ "Interface language": "介面語言",
+ "Intersect": "交集",
+ "Intersect with": "與下列項目取交集",
+ "Into one folder": "放入同一資料夾",
+ "Invert": "反轉",
+ "Invert Mask": "反轉遮色片",
+ "JPEG (Large)": "JPEG(大尺寸)",
+ "JPEG (Small)": "JPEG(小尺寸)",
+ "Join the ArtCraft Discord": "加入 ArtCraft Discord",
+ "Join the ArtCraft Discord…": "加入 ArtCraft Discord…",
+ "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 (K)": "關鍵字(K)",
+ "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": "橫向色差",
+ "Layer": "圖層",
+ "Layers": "圖層",
+ "Leave them": "保留這些照片",
+ "Lens": "鏡頭",
+ "Lens Corrections": "鏡頭校正",
+ "Level": "水平校正",
+ "Library": "照片庫",
+ "Library Info": "照片庫資訊",
+ "Library Originals": "照片庫原始檔案",
+ "Library Preferences": "照片庫偏好設定",
+ "Library State": "照片庫狀態",
+ "Light": "光線",
+ "LightCraft Default": "LightCraft 預設值",
+ "LightCraft Help": "LightCraft 輔助說明",
+ "LightCraft Preset": "LightCraft 預設集",
+ "LightCraft Presets": "LightCraft 預設集",
+ "LightCraft Website": "LightCraft 網站",
+ "LightCraft couldn't find where to keep your library.": "LightCraft 找不到儲存照片庫的位置。",
+ "LightCraft couldn't open the library at": "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 Albums": "列出相簿",
+ "List Develop Controls": "列出顯像控制項",
+ "List History": "列出歷史紀錄",
+ "List Presets": "列出預設集",
+ "List Profiles": "列出描述檔",
+ "List this folder in Local from now on": "將此資料夾永久列在本機",
+ "Local": "本機",
+ "Local folders": "本機資料夾",
+ "Local library — no cloud account needed": "本機照片庫,無需雲端帳號",
+ "Locate Missing File…": "尋找遺失的檔案…",
+ "Locate Photo": "尋找照片",
+ "Location": "位置",
+ "Long Edge": "長邊",
+ "Looking for photos…": "正在尋找照片…",
+ "Lossless": "無損",
+ "Low": "低",
+ "Luminance": "明度",
+ "Luminance Range": "明度範圍",
+ "Lush Greens": "濃郁綠意",
+ "Magenta": "洋紅色",
+ "Magenta Hue": "洋紅色色相",
+ "Magenta Luminance": "洋紅色明度",
+ "Magenta Saturation": "洋紅色飽和度",
+ "Make Candidate the Select": "將候選照片設為選定照片",
+ "Manual": "手動",
+ "Marquee zoom": "選取範圍縮放",
+ "Masking": "遮色片",
+ "Masking (M)": "遮色片(M)",
+ "Masks": "遮色片",
+ "Match": "符合",
+ "Match Total Exposures": "符合總曝光量",
+ "Matte": "霧面",
+ "Matte Soft": "柔和霧面",
+ "Med": "中",
+ "Medium Contrast": "中等對比",
+ "Megapixels": "百萬像素",
+ "Memory Budget": "記憶體預算",
+ "Memory Usage": "記憶體使用量",
+ "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": "遺失的照片",
+ "Misty Morning": "薄霧清晨",
+ "Mode": "模式",
+ "Modified Date": "修改日期",
+ "Monday": "星期一",
+ "Mono Red Filter": "黑白紅色濾鏡",
+ "Mono Rich": "濃郁黑白",
+ "Mono Soft": "柔和黑白",
+ "Monochrome": "單色",
+ "Month": "月",
+ "Moody": "氛圍風格",
+ "Moody Portrait": "氛圍人像",
+ "More": "更多",
+ "More preset options": "更多預設集選項",
+ "Move": "移動",
+ "Move Album": "移動相簿",
+ "Move Down in Stack": "在堆疊中下移",
+ "Move Folder": "移動資料夾",
+ "Move Folder To…": "將資料夾移至…",
+ "Move Mask": "移動遮色片",
+ "Move Preset to Group": "將預設集移至群組",
+ "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 附屬檔案只會在複本通過驗證後才從來源移除。重複及失敗的檔案會留在原處。",
+ "Muted": "低彩度",
+ "Muted Film": "低彩度底片",
+ "My Photos": "我的照片",
+ "My Photos Panel": "我的照片面板",
+ "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": "需要調整",
+ "Neon Dusk": "霓虹暮色",
+ "Neon Night": "霓虹之夜",
+ "Neutral": "中性",
+ "Never": "永不",
+ "New": "新增",
+ "New Album": "新增相簿",
+ "New Album…": "新增相簿…",
+ "New Folder…": "新增資料夾…",
+ "New Group…": "新增群組…",
+ "New Smart Album": "新增智慧型相簿",
+ "New Smart Album from Filter…": "依篩選條件新增智慧型相簿…",
+ "New Smart Album…": "新增智慧型相簿…",
+ "New album": "新增相簿",
+ "New album…": "新增相簿…",
+ "New blank PDF": "新增空白 PDF",
+ "New name": "新名稱",
+ "New…": "新增…",
+ "Next Photo": "下一張照片",
+ "Night Blue": "夜色藍調",
+ "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": "沒有可總覽的照片",
+ "Nudge Develop Value": "微調顯像數值",
+ "OK": "確定",
+ "Object": "物件",
+ "Off": "關閉",
+ "Offset X": "X 位移",
+ "Offset Y": "Y 位移",
+ "Opacity": "不透明度",
+ "Opacity (%)": "不透明度(%)",
+ "Open Library": "開啟照片庫",
+ "Open Library…": "開啟照片庫…",
+ "Open in": "開啟於",
+ "Open in Detail": "在詳細檢視中開啟",
+ "Open the place in OpenStreetMap": "在 OpenStreetMap 中開啟此位置",
+ "Open…": "開啟…",
+ "Optics": "光學",
+ "Optimize Library": "最佳化照片庫",
+ "Optional name": "名稱(選填)",
+ "Options": "選項",
+ "Orange": "橙色",
+ "Orange Hue": "橙色色相",
+ "Orange Luminance": "橙色明度",
+ "Orange Saturation": "橙色飽和度",
+ "Orientation": "方向",
+ "Original": "原始",
+ "Original + Settings": "原始檔與設定",
+ "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": "覆寫",
+ "Pages": "頁面",
+ "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 with Last Settings": "使用上次設定合併全景",
+ "Panorama…": "全景…",
+ "Parametric curve": "參數曲線",
+ "Paste": "貼上",
+ "Paste Edit Settings": "貼上編輯設定",
+ "Paste Metadata": "貼上中繼資料",
+ "Paste Selected Settings": "貼上選取的設定",
+ "Paste Selected Settings…": "貼上選取的設定…",
+ "Paste Settings from Previous": "貼上前一張照片的設定",
+ "Paste pages": "貼上頁面",
+ "Pastel Haze": "粉彩薄霧",
+ "Percent": "百分比",
+ "Percentage": "百分比",
+ "Performance": "效能",
+ "Perspective": "透視",
+ "Pet Eye": "寵物眼睛",
+ "Pet Eye Catchlight": "寵物眼神光",
+ "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": "位置",
+ "Preferences": "偏好設定",
+ "Preferences…": "偏好設定…",
+ "Preset": "預設集",
+ "Preset name": "預設集名稱",
+ "Presets": "預設集",
+ "Presets & Profiles": "預設集與描述檔",
+ "Presets (Shift+P)": "預設集(Shift+P)",
+ "Presets ▾": "預設集",
+ "Preview": "預覽",
+ "Preview Build Progress": "預覽建立進度",
+ "Preview only": "僅供預覽",
+ "Preview size": "預覽尺寸",
+ "Previews": "預覽",
+ "Previous Photo": "上一張照片",
+ "Print": "列印",
+ "Print…": "列印…",
+ "Profile": "描述檔",
+ "Profile Browser": "描述檔瀏覽器",
+ "Profile Distortion": "描述檔變形校正",
+ "Profile Menu": "描述檔選單",
+ "Profile Vignetting": "描述檔暗角校正",
+ "Profile:": "描述檔:",
+ "Profiles": "描述檔",
+ "Projection": "投影",
+ "Properties": "屬性",
+ "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": "品質",
+ "Query Photos": "搜尋照片",
+ "Quick Collection": "快速集合",
+ "Quick Develop": "快速顯像",
+ "Quiet Green": "靜謐綠色",
+ "Quit": "結束",
+ "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": "從檔案讀取中繼資料",
+ "Read mode": "閱讀模式",
+ "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": "參照檔案目前的位置",
+ "Refine Mask Edges": "調整遮色片邊緣",
+ "Refresh Source": "重新整理來源",
+ "Refresh Source (/)": "重新整理來源(/)",
+ "Reject": "排除",
+ "Reject below focus": "排除低於此焦點分數的照片",
+ "Rejected": "已排除",
+ "Rejects": "排除的照片",
+ "Reload from Disk": "從磁碟重新載入",
+ "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 Folder…": "重新命名資料夾…",
+ "Rename Keyword": "重新命名關鍵字",
+ "Rename Keyword…": "重新命名關鍵字…",
+ "Rename Mask": "重新命名遮色片",
+ "Rename Photos": "重新命名照片",
+ "Rename Photos…": "重新命名照片…",
+ "Rename Preset": "重新命名預設集",
+ "Rename Preview": "重新命名預覽",
+ "Rename Template Tags": "重新命名範本標籤",
+ "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": "還原",
+ "Restore Library from Backup…": "從備份還原照片庫…",
+ "Restore Version": "還原版本",
+ "Retouch": "修飾",
+ "Revert": "回復至上次儲存",
+ "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 Crop Aspect": "旋轉裁切長寬比",
+ "Rotate Left": "向左旋轉",
+ "Rotate Right": "向右旋轉",
+ "Roughness": "粗糙度",
+ "Roundness": "圓度",
+ "Same as raw default": "與 RAW 預設值相同",
+ "Sample Mask Color": "取樣遮色片色彩",
+ "Sample a colour on the photo": "在照片上取樣色彩",
+ "Saturation": "飽和度",
+ "Saturation Range": "飽和度範圍",
+ "Saturday": "星期六",
+ "Save": "儲存",
+ "Save As…": "另存新檔…",
+ "Save Color Label Set": "儲存色彩標籤集",
+ "Save Current Filter as Preset…": "將目前篩選條件儲存為預設集…",
+ "Save Current Keywords as Set…": "將目前關鍵字儲存為關鍵字集…",
+ "Save Export Preset": "儲存匯出預設集",
+ "Save Filter Preset": "儲存篩選預設集",
+ "Save Keyword Set": "儲存關鍵字集",
+ "Save Metadata Preset": "儲存中繼資料預設集",
+ "Save Metadata Preset…": "儲存中繼資料預設集…",
+ "Save Metadata to File": "將中繼資料儲存至檔案",
+ "Save Point Curve Preset": "儲存點曲線預設集",
+ "Save Point Curve…": "儲存點曲線…",
+ "Save as Preset": "儲存為預設集",
+ "Save as Smart Album…": "儲存為智慧型相簿…",
+ "Save as set": "儲存為關鍵字集",
+ "Save as…": "另存新檔…",
+ "Save preset": "儲存預設集",
+ "Scale": "縮放",
+ "Scanning": "正在掃描",
+ "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "分數會保留在照片上:可依焦點與相似照片中的最佳照片篩選,或建立智慧型相簿。",
+ "Screen": "螢幕",
+ "Search Photos": "搜尋照片",
+ "Search…": "搜尋…",
+ "Seasons": "四季",
+ "Second Window": "第二視窗",
+ "Select": "選定照片",
+ "Select All": "全選",
+ "Select Luminance Range": "選取明度範圍",
+ "Select Mask": "選取遮色片",
+ "Select Photos": "選取照片",
+ "Select Spot": "選取斑點",
+ "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": "已選取",
+ "Selenium Tone": "硒調色",
+ "Send Feedback…": "傳送意見回饋…",
+ "Sepia Tone": "深褐色調",
+ "Services": "服務",
+ "Set Before": "設定編輯前",
+ "Set Color Label": "設定色彩標籤",
+ "Set Crop": "設定裁切",
+ "Set Develop Values": "設定顯像參數",
+ "Set Flag": "設定旗標",
+ "Set Mask Adjustments": "設定遮色片調整",
+ "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 Mask": "顯示/隱藏遮色片",
+ "Show/hide My Photos panel": "顯示/隱藏我的照片面板",
+ "Shutter speed": "快門速度",
+ "Side panels": "側邊面板",
+ "Sidecar names": "附屬檔案名稱",
+ "Size": "尺寸",
+ "Size (% of short edge)": "尺寸(短邊百分比)",
+ "Size limit": "大小上限",
+ "Skip": "略過",
+ "Sky": "天空",
+ "Slideshow": "幻燈片播放",
+ "Smart Album": "智慧型相簿",
+ "Smart Album Rule Fields": "智慧型相簿規則欄位",
+ "Smart Preview Status": "智慧型預覽狀態",
+ "Smart Previews Location": "智慧型預覽位置",
+ "Smart previews": "智慧型預覽",
+ "Smoothness": "平滑度",
+ "Soft B&W": "柔和黑白",
+ "Soft Pastel": "柔和粉彩",
+ "Soft Proofing": "軟式校樣",
+ "Soft Skin": "柔和膚質",
+ "Sort": "排序",
+ "Sort by": "排序依據",
+ "Source": "來源",
+ "Source code on GitHub": "GitHub 上的原始碼",
+ "Spherical": "球面",
+ "Spiral": "螺旋",
+ "Split Stack": "分割堆疊",
+ "Spring Fresh": "清新春日",
+ "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": "拉直工具",
+ "Strong Contrast": "強烈對比",
+ "Style": "樣式",
+ "Subfolder": "子資料夾",
+ "Subfolder name": "子資料夾名稱",
+ "Subject": "主體",
+ "Subtract": "減去",
+ "Suggestions": "建議",
+ "Summer Haze": "夏日薄霧",
+ "Sunday": "星期日",
+ "Survey": "總覽",
+ "Survey (N)": "總覽(N)",
+ "Swap": "交換",
+ "Swap Aspect (X)": "交換長寬比(X)",
+ "Swap Before and After Settings": "交換編輯前與編輯後設定",
+ "Swap Compare Photos": "交換比較照片",
+ "Switch light / dark theme": "切換淺色/深色主題",
+ "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…",
+ "Take a snapshot": "擷取快照",
+ "Targeted Adjustment": "目標調整",
+ "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": "目標調整:在照片上向上/下拖曳,調整該位置的曲線",
+ "Teal & Amber": "青綠與琥珀",
+ "Teal & Orange": "青綠與橙色",
+ "Temp": "色溫",
+ "Temperature": "色溫",
+ "Template": "範本",
+ "Temporary session — your library isn't open, and nothing you do here is saved.": "暫存工作階段:照片庫未開啟,此處的操作不會儲存。",
+ "Text": "文字",
+ "Text direction": "文字方向",
+ "Texture": "紋理",
+ "The original files, unchanged, each with an XMP sidecar holding its edits.": "保留未變更的原始檔案,每個檔案附有記錄編輯設定的 XMP 附屬檔案。",
+ "The photo has these settings": "此照片具有這些設定",
+ "Theme": "主題",
+ "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": "縮圖快取",
+ "Thursday": "星期四",
+ "Tint": "色調",
+ "Title": "標題",
+ "Toggle Keyword from Set": "切換關鍵字集中的關鍵字",
+ "Toggle Section": "切換區段",
+ "Toggle Zoom": "切換縮放",
+ "Tone Curve": "色調曲線",
+ "Tools": "工具",
+ "Top Level": "最上層",
+ "Transfer": "傳輸",
+ "Treatment": "處理方式",
+ "Triangle": "三角形",
+ "Try Again": "再試一次",
+ "Tuesday": "星期二",
+ "Tungsten": "鎢絲燈",
+ "Turn Off": "關閉",
+ "Turn on Include subfolders, or pick another folder under Local": "啟用「包含子資料夾」,或在本機中選擇其他資料夾",
+ "Type": "類型",
+ "Uncheck All": "全部取消勾選",
+ "Undo": "復原",
+ "Unedited": "未編輯",
+ "Unflag": "移除旗標",
+ "Unflagged": "無旗標",
+ "Ungroup Stack": "取消堆疊",
+ "Unknown Date": "未知日期",
+ "Unrated": "未評分",
+ "Unreadable": "無法讀取",
+ "Update": "更新",
+ "Update Mask Shape": "更新遮色片形狀",
+ "Update Preset with Current Settings": "以目前設定更新預設集",
+ "Update Rules from Current Filter": "依目前篩選條件更新規則",
+ "Update Version": "更新版本",
+ "Update Version with Current Settings": "以目前設定更新版本",
+ "Update with Current Settings": "以目前設定更新",
+ "Upright": "透視校正",
+ "Urban": "城市",
+ "Use Keyword Set": "使用關鍵字集",
+ "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": "垂直",
+ "Vertical text": "垂直文字",
+ "Vibrance": "自然飽和度",
+ "Videos": "影片",
+ "View": "檢視",
+ "Vignette": "暗角",
+ "Vignetting": "暗角",
+ "Vintage": "復古",
+ "Virtual Copy": "虛擬副本",
+ "Visible": "可見照片",
+ "Visualize Spots": "顯示斑點",
+ "Visualize Spots (A)": "顯示斑點(A)",
+ "Visualize range": "顯示範圍",
+ "Vivid": "鮮豔",
+ "Vivid Pop": "鮮明色彩",
+ "Warm Film": "暖調底片",
+ "Warm Glow": "暖光",
+ "Warm Print": "暖調沖印",
+ "Warm Table": "溫暖餐桌",
+ "Watched folder": "監看的資料夾",
+ "Watermark": "浮水印",
+ "Wednesday": "星期三",
+ "What's New": "新增功能",
+ "When rated or hovered": "評分或游標停留時",
+ "White Balance": "白平衡",
+ "White Balance Selector": "白平衡選取器",
+ "White Balance Selector (W)": "白平衡選取器(W)",
+ "Whites": "白色色階",
+ "Width": "寬度",
+ "Width (% of photo)": "寬度(照片百分比)",
+ "Width (px)": "寬度(像素)",
+ "Width × Height": "寬度 × 高度",
+ "Window": "視窗",
+ "Winter Blue": "冬日藍調",
+ "XMP Sidecar Preferences": "XMP 附屬檔案偏好設定",
+ "XMP sidecars": "XMP 附屬檔案",
+ "Year": "年",
+ "Yellow": "黃色",
+ "Yellow Hue": "黃色色相",
+ "Yellow Luminance": "黃色明度",
+ "Yellow Saturation": "黃色飽和度",
+ "Yes": "是",
+ "Your library couldn't be opened": "無法開啟你的照片庫",
+ "Zoom": "縮放",
+ "Zoom 100%": "縮放至 100%",
+ "Zoom In": "放大",
+ "Zoom Out": "縮小",
+ "Zoom to Fit": "縮放至符合視窗",
+ "Zoom to page level": "符合頁面大小",
+ "and": "且",
+ "comma, separated": "以逗號分隔",
+ "memory only": "僅記憶體",
+ "none": "無",
+ "not available here": "此處無法使用",
+ "of the following rules:": "下列規則:",
+ "this photo": "這張照片",
+ "日本語": "日本語",
+ "繁體中文(台灣)": "繁體中文(台灣)"
+}
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index 9e1f09252..4dff9089b 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -267,6 +267,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
u.dialog = app.ui.dialog.clone();
u.status = app.ui.status.clone();
app.ui = u;
+ crate::i18n::set_language(app.ui.language);
ctx.request_repaint();
ok(serde_json::to_value(&app.ui).unwrap_or_default())
}
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 0e2389a44..bf5aa507c 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -3,7 +3,7 @@ use std::{cell::Cell, collections::BTreeMap, sync::OnceLock};
use serde::{Deserialize, Serialize};
-include!(concat!(env!("OUT_DIR"), "/ja-formats.rs"));
+include!(concat!(env!("OUT_DIR"), "/formats.rs"));
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Language {
@@ -12,25 +12,33 @@ pub enum Language {
En,
#[serde(rename = "ja")]
Ja,
+ #[serde(rename = "zh-tw", alias = "zh-TW", alias = "zh-Hant", alias = "zh-hant")]
+ ZhTw,
}
impl Language {
- pub const ALL: [Self; 2] = [Self::En, Self::Ja];
+ pub const ALL: [Self; 3] = [Self::En, Self::Ja, Self::ZhTw];
pub fn name(self) -> &'static str {
match self {
Self::En => "English",
Self::Ja => "日本語",
+ Self::ZhTw => "繁體中文(台灣)",
}
}
pub fn parse(code: &str) -> Option {
- match code {
+ match code.to_ascii_lowercase().as_str() {
"en" => Some(Self::En),
"ja" => Some(Self::Ja),
+ "zh-tw" | "zh-hant" => Some(Self::ZhTw),
_ => None,
}
}
pub fn tr(self, source: &str) -> &str {
- if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
+ match self {
+ Self::En => source,
+ Self::Ja => japanese().get(source).map(String::as_str).unwrap_or(source),
+ Self::ZhTw => traditional_chinese().get(source).map(String::as_str).unwrap_or(source),
+ }
}
}
@@ -39,15 +47,20 @@ thread_local! {
}
pub fn default_language() -> Language {
- if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En }
+ std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|code| Language::parse(&code)).unwrap_or_default()
}
pub fn set_language(language: Language) {
LANGUAGE.with(|value| value.set(language));
}
+pub fn language() -> Language {
+ LANGUAGE.with(Cell::get)
+}
+
+/// Kept for callers of the original two-language API.
pub fn is_japanese() -> bool {
- LANGUAGE.with(|value| value.get() == Language::Ja)
+ language() == Language::Ja
}
fn japanese() -> &'static BTreeMap {
@@ -60,16 +73,255 @@ fn japanese() -> &'static BTreeMap {
})
}
+fn traditional_chinese() -> &'static BTreeMap {
+ static MESSAGES: OnceLock> = OnceLock::new();
+ MESSAGES.get_or_init(|| {
+ serde_json::from_str(include_str!("../locales/zh-tw.json")).unwrap_or_else(|error| {
+ log::error!("Invalid Traditional Chinese message catalog: {error}");
+ BTreeMap::new()
+ })
+ })
+}
+
/// 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 }
+ language().tr(source)
+}
+
+/// Display names from stock presets/profiles; imported and editable names remain verbatim.
+pub fn builtin_label(source: &str, builtin: bool) -> &str {
+ if builtin { tr(source) } else { source }
+}
+
+/// Display-only history labels. Preserve custom preset names within generated steps.
+pub fn history_label<'a>(source: &'a str, presets: &'a [lightcraft_develop::Preset]) -> String {
+ if let Some(name) = source.strip_prefix("Preset: ") {
+ let builtin = presets.iter().any(|p| p.builtin && p.name == name) && !presets.iter().any(|p| !p.builtin && p.name == name);
+ return tr_format!("Preset: {name}", name = builtin_label(name, builtin));
+ }
+ if let Some(name) = source.strip_prefix("Reset ") {
+ return tr_format!("Reset {name}", name = tr(name));
+ }
+ if let Some(name) = source.strip_prefix("Quick Develop: ") {
+ return tr_format!("Quick Develop: {name}", name = tr(name));
+ }
+ tr(source).to_string()
+}
+
+/// Built-in library headings, without translating an album's editable name.
+pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcraft_catalog::Catalog) -> String {
+ let label = source.label(catalog);
+ if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() }
+}
+
+/// Calendar headings for the grid and the narrower date sidebar. ISO keys stay unchanged.
+pub fn date_group_label(key: &str, short: bool) -> String {
+ let label = lightcraft_catalog::dates::group_label(key);
+ if language() != Language::ZhTw {
+ return if short {
+ match key.len() {
+ 7 => label.split(' ').next().unwrap_or(&label).to_string(),
+ 10 => label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(),
+ _ => label,
+ }
+ } else {
+ label
+ };
+ }
+ let Some(year) = key.get(..4).and_then(|y| y.parse::().ok()) else { return tr(&label).to_string() };
+ if key.len() == 4 {
+ return tr_format!("{year}", year = year);
+ }
+ let Some(month) = key.get(5..7).and_then(|m| m.parse::().ok()).filter(|m| (1..=12).contains(m)) else {
+ return tr(&label).to_string();
+ };
+ if key.len() == 7 {
+ return if short { tr_format!("{month}", month = month) } else { tr_format!("{month} {year}", month = month, year = year) };
+ }
+ if key.len() == 10
+ && let Some(weekday) = lightcraft_catalog::dates::weekday(key)
+ && let Some(day) = key.get(8..10).and_then(|d| d.parse::().ok())
+ {
+ let weekday = tr(weekday);
+ return if short {
+ tr_format!("{weekday}, {day}", weekday = weekday, day = day)
+ } else {
+ tr_format!("{weekday}, {day} {month} {year}", weekday = weekday, day = day, month = month, year = year)
+ };
+ }
+ tr(&label).to_string()
+}
+
+/// Localized capture times for display; the source metadata is never rewritten.
+pub fn display_time(iso: &str) -> String {
+ if language() != Language::ZhTw || lightcraft_catalog::dates::normalize_iso(iso).is_none() {
+ return lightcraft_catalog::dates::display_time(iso);
+ }
+ let date = date_group_label(iso.get(..10).unwrap_or(iso), false);
+ match iso.get(11..).filter(|time| !time.is_empty()) {
+ Some(time) => format!("{date} {time}"),
+ None => date,
+ }
}
#[cfg(test)]
mod tests {
use super::*;
+ #[test]
+ fn traditional_chinese_catalog_covers_commands_and_japanese_messages() {
+ let messages = traditional_chinese();
+ for key in japanese().keys() {
+ assert!(messages.get(key).is_some_and(|value| !value.is_empty()), "missing: {key}");
+ }
+ for preset in lightcraft_engine::presets::builtin() {
+ for key in [&preset.name, &preset.group] {
+ assert!(messages.contains_key(key), "missing built-in preset label: {key}");
+ }
+ }
+ for profile in lightcraft_engine::presets::PROFILES {
+ for key in [profile.name, profile.group] {
+ assert!(messages.contains_key(key), "missing profile label: {key}");
+ }
+ }
+ for command in lightcraft_engine::command_specs() {
+ assert!(messages.contains_key(command.label), "missing command: {}", command.label);
+ }
+ for (_, label, _, _) in crate::menus::UI_COMMANDS {
+ assert!(messages.contains_key(*label), "missing UI command: {label}");
+ }
+ }
+
+ #[test]
+ fn traditional_chinese_switches_through_menu_and_control_and_persists() {
+ use crate::control::{ControlRequest, Outcome};
+ let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
+ app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap();
+ assert_eq!(app.ui.language, Language::ZhTw);
+ assert_eq!(tr("File"), "檔案");
+ assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片");
+ assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日");
+ assert_eq!(date_group_label("2026-09-20", true), "20日 星期日");
+ assert_eq!(date_group_label("2026-09", false), "2026年9月");
+ assert_eq!(date_group_label("2026-09", true), "9月");
+ assert_eq!(date_group_label("2026", false), "2026年");
+ assert_eq!(date_group_label("", false), "未知日期");
+ assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05");
+ assert_eq!(display_time("a-user-value"), "a-user-value");
+ assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true));
+ assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false));
+ assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
+ assert_eq!(tr("develop.set"), "develop.set");
+ for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] {
+ assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color");
+ }
+ assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩");
+ assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片");
+ assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片");
+ let saved = serde_json::to_string(&app.ui).unwrap();
+ assert_eq!(serde_json::from_str::(&saved).unwrap().language, Language::ZhTw);
+ let ctx = egui::Context::default();
+ for code in ["zh-tw", "zh-TW", "zh-hant", "zh-Hant"] {
+ assert_eq!(Language::parse(code), Some(Language::ZhTw));
+ let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
+ let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") };
+ assert_eq!(reply["ok"], true);
+ assert_eq!(app.ui.language, Language::ZhTw);
+ }
+ let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"}));
+ let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") };
+ assert_eq!(reply["ok"], false);
+ assert_eq!(app.ui.language, Language::ZhTw);
+ let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>();
+ let chinese_ids = ids(&app);
+ app.run("app.language.english", serde_json::json!({})).unwrap();
+ assert_eq!(tr("File"), "File");
+ assert_eq!(chinese_ids, ids(&app));
+ }
+
+ #[test]
+ fn traditional_chinese_presets_and_panel_headers_are_painted() {
+ let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
+ app.ui.language = Language::ZhTw;
+ app.ui.presets = true;
+ let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone();
+ app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin });
+ for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] {
+ app.ui.right = panel;
+ let text = painted_text(&mut app);
+ assert!(text.contains(title), "{text}");
+ assert!(!text.contains("History"), "{text}");
+ assert!(!text.contains("Versions"), "{text}");
+ assert!(text.contains("暖光"), "built-in preset: {text}");
+ assert!(text.contains("Warm Glow"), "user preset: {text}");
+ assert!(text.contains("色彩"), "built-in group: {text}");
+ assert!(text.contains("Color"), "user group: {text}");
+ }
+ assert_eq!(history_label("Exposure", &app.session.presets), "曝光");
+ assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片");
+ assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow");
+ set_language(Language::En);
+ }
+
+ fn painted_text(app: &mut crate::LightcraftApp) -> 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)),
+ _ => {}
+ }
+ }
+ let ctx = egui::Context::default();
+ crate::theme::install_fonts(&ctx);
+ 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);
+ });
+ out.textures_delta.clear();
+ text.clear();
+ for shape in out.shapes {
+ collect(&shape.shape, &mut text);
+ }
+ }
+ text
+ }
+
+ #[test]
+ fn traditional_chinese_fonts_cover_static_and_dynamic_catalogs() {
+ if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() {
+ eprintln!("skipped glyph coverage: build with CRAFT_FONTS_DIR");
+ return;
+ }
+ let formats: BTreeMap = serde_json::from_str(include_str!("../locales/zh-tw-formats.json")).unwrap();
+ 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);
+ let mut missing = std::collections::BTreeSet::new();
+ for message in traditional_chinese().values().chain(formats.values()).map(String::as_str).chain([Language::ZhTw.name()]) {
+ // egui reports false for glyphs of Monospace's first (replacement)
+ // face, Hack. Check its CJK fallback; proportional/semibold check all text.
+ for ch in message.chars().filter(|ch| !ch.is_whitespace()) {
+ if font.family == egui::FontFamily::Monospace && !('\u{4e00}'..='\u{9fff}').contains(&ch) {
+ continue;
+ }
+ if !fonts.has_glyph(&font, ch) {
+ missing.insert(ch);
+ }
+ }
+ }
+ assert!(missing.is_empty(), "Missing glyphs in {font:?}: {missing:?}");
+ }
+ });
+ }
+
#[test]
fn catalog_is_valid_and_contains_core_workflows() {
let messages: BTreeMap = serde_json::from_str(include_str!("../locales/ja.json")).unwrap();
diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs
index f122041e0..ef422d792 100644
--- a/crates/ui-egui/src/menubar.rs
+++ b/crates/ui-egui/src/menubar.rs
@@ -272,6 +272,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option {
match id {
"app.language.english" => Some(u.language == crate::i18n::Language::En),
"app.language.japanese" => Some(u.language == crate::i18n::Language::Ja),
+ "app.language.traditionalChinese" => Some(u.language == crate::i18n::Language::ZhTw),
"develop.autoSync" => Some(app.session.auto_sync),
"view.photoCounts" => Some(u.show_counts),
"view.secondWindow" => Some(u.second_window),
@@ -316,12 +317,18 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option {
fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String {
let n = app.session.selection.ids.len();
match id {
- "edit.undo" => {
- app.session.undo.last().map(|e| crate::i18n::tr_format!("Undo {}", crate::i18n::tr(&e.label))).unwrap_or_else(|| "Undo".into())
- }
- "edit.redo" => {
- app.session.redo.last().map(|e| crate::i18n::tr_format!("Redo {}", crate::i18n::tr(&e.label))).unwrap_or_else(|| "Redo".into())
- }
+ "edit.undo" => app
+ .session
+ .undo
+ .last()
+ .map(|e| crate::i18n::tr_format!("Undo {}", crate::i18n::history_label(&e.label, &app.session.presets)))
+ .unwrap_or_else(|| "Undo".into()),
+ "edit.redo" => app
+ .session
+ .redo
+ .last()
+ .map(|e| crate::i18n::tr_format!("Redo {}", crate::i18n::history_label(&e.label, &app.session.presets)))
+ .unwrap_or_else(|| "Redo".into()),
"photo.delete" if n > 1 => crate::i18n::tr_format!("Delete {n} Photos", n = n),
"photo.virtualCopy" if n > 1 => crate::i18n::tr_format!("Create {n} Virtual Copies", n = n),
"dialog.rename" if n > 1 => crate::i18n::tr_format!("Rename {n} Photos…", n = n),
@@ -341,19 +348,26 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> {
item(
"library.buildPreviews",
json!({"size": "standard", "edge": app.ui.settings.preview_edge}),
- format!("Build Standard-Sized Previews ({scope})"),
+ crate::i18n::tr_format!("Build Standard-Sized Previews ({scope})", scope = crate::i18n::tr(scope)),
+ None,
+ !running,
+ None,
+ ),
+ item(
+ "library.buildPreviews",
+ json!({"size": "full"}),
+ crate::i18n::tr_format!("Build 1:1 Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
!running,
None,
),
- item("library.buildPreviews", json!({"size": "full"}), format!("Build 1:1 Previews ({scope})"), None, !running, None),
item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None),
MenuNode::Separator,
// (read and written on a worker thread: the originals may be on a slow drive)
item(
"library.smartPreviews",
json!({"background": true}),
- format!("Build Smart Previews ({scope})"),
+ crate::i18n::tr_format!("Build Smart Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
app.session.media.smart_dir.is_some() && !running,
None,
@@ -361,7 +375,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> {
item(
"library.smartPreviews",
json!({"discard": true, "background": true}),
- format!("Discard Smart Previews ({scope})"),
+ crate::i18n::tr_format!("Discard Smart Previews ({scope})", scope = crate::i18n::tr(scope)),
None,
app.session.media.smart_dir.is_some() && !running,
None,
@@ -394,7 +408,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> {
.map(|(l, sc)| {
let name = format!("{l:?}");
let label = match app.session.catalog.custom_label_name(*l) {
- Some(custom) => format!("{custom} ({name})"),
+ Some(custom) => format!("{custom} ({})", crate::i18n::tr(&name)),
None => name.clone(),
};
item(
@@ -418,7 +432,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> {
v.push(item(
"label.applySet",
json!({"name": name}),
- format!("Label Set: {name}"),
+ crate::i18n::tr_format!("Label Set: {name}", name = name),
None,
true,
Some(current.as_deref() == Some(name)),
@@ -499,7 +513,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> {
item(
"library.selectBy",
json!({"label": name.to_lowercase()}),
- crate::i18n::tr_format!("{name} Label", name = name),
+ crate::i18n::tr_format!("{name} Label", name = crate::i18n::tr(&name)),
None,
true,
None,
@@ -516,7 +530,8 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> {
v.push(MenuNode::Separator);
}
builtin = b;
- v.push(item("app.export", json!({"preset": p.name, "background": true}), p.name, None, sel, None));
+ let label = crate::i18n::builtin_label(&p.name, b).to_string();
+ v.push(item("app.export", json!({"preset": p.name, "background": true}), label, None, sel, None));
}
v.push(MenuNode::Separator);
v.push(item("dialog.export", Value::Null, "Custom…", None, sel, None));
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 5d838b759..e3db49dc8 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -13,6 +13,7 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static
pub const UI_COMMANDS: &[UiCommand] = &[
("app.language.english", "English", None, "Edit>Language"),
("app.language.japanese", "日本語", None, "Edit>Language"),
+ ("app.language.traditionalChinese", "繁體中文(台灣)", 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)
@@ -151,10 +152,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) {
if app.ui.right == p {
app.ui.right = RightPanel::None;
- app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = name));
+ app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name)));
} else {
app.ui.right = p;
- app.toast(ctx, crate::i18n::tr_format!("{name} On", name = name));
+ app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name)));
if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) {
app.ui.view = ViewMode::Detail;
}
@@ -206,8 +207,13 @@ 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 matches!(id, "app.language.english" | "app.language.japanese" | "app.language.traditionalChinese") {
+ app.ui.language = match id {
+ "app.language.japanese" => crate::i18n::Language::Ja,
+ "app.language.traditionalChinese" => crate::i18n::Language::ZhTw,
+ _ => crate::i18n::Language::En,
+ };
+ crate::i18n::set_language(app.ui.language);
return Some(Ok(json!(app.ui.language)));
}
let ctx = egui::Context::default();
diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs
index fe8b04781..710b5d4e1 100644
--- a/crates/ui-egui/src/panels/dialogs.rs
+++ b/crates/ui-egui/src/panels/dialogs.rs
@@ -466,7 +466,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.separator();
}
builtin = b;
- if ui.selectable_label(false, &p.name).clicked() {
+ if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() {
chosen = Some(p.name);
}
}
@@ -701,7 +701,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Import { opts } => crate::import::body(app, ui, opts),
Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab),
Dialog::ConfirmDelete { count } => {
- let what = if *count == 1 { "this photo".to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
+ let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
}
@@ -710,7 +710,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.add_space(10.0);
- let discord = egui::Button::new(egui::RichText::new("Join the ArtCraft Discord").font(t.semibold(15.0)).color(egui::Color32::WHITE))
+ let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)
.min_size(egui::vec2(260.0, 34.0));
let r = ui.add(discord).on_hover_text(crate::links::DISCORD);
@@ -724,7 +724,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
("Source code on GitHub", crate::links::GITHUB),
("ArtCraft — more creative apps", crate::links::WEBSITE),
] {
- let r = ui.link(label).on_hover_text(url);
+ let r = ui.link(crate::i18n::tr(label)).on_hover_text(url);
if r.clicked() {
let _ = crate::links::open(app, url);
}
@@ -776,7 +776,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Import { opts } => {
let n = opts.selected_paths().len();
let verb = if opts.copy && opts.move_files { "Move" } else { "Import" };
- add_label = format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" });
+ add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n);
add_label.as_str()
}
Dialog::Merge { .. } => "Merge",
diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs
index ff9d9a261..cddb984e1 100644
--- a/crates/ui-egui/src/panels/edit.rs
+++ b/crates/ui-egui/src/panels/edit.rs
@@ -140,8 +140,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim));
let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color");
- let r =
- crate::widgets::dropdown(ui, "profile", if name == "Color" { crate::i18n::tr("Color") } else { name }, t.font(15.0), t.text_label);
+ let r = crate::widgets::dropdown(ui, "profile", crate::i18n::tr(name), t.font(15.0), t.text_label);
egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() {
@@ -356,7 +355,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
key: &str,
p: &'static lightcraft_engine::presets::ProfileInfo,
pick: &mut (Option<&'static str>, Option<&'static str>)| {
- let r = ui.selectable_label(p.id == cur, p.name);
+ let r = ui.selectable_label(p.id == cur, crate::i18n::tr(p.name));
register(ui.ctx(), format!("profileMenu:{key}:{}", p.id), r.rect);
if r.clicked() {
pick.0 = Some(p.id);
@@ -366,7 +365,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
}
};
let heading = |ui: &mut egui::Ui, s: &str| {
- ui.label(egui::RichText::new(s).font(t.semibold(11.5)).color(t.text_dim));
+ ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim));
};
for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] {
let list: Vec<_> = ids.iter().filter_map(|id| profile(id)).collect();
@@ -380,7 +379,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
ui.separator();
}
for g in profile_groups() {
- let r = ui.menu_button(g, |ui| {
+ let r = ui.menu_button(crate::i18n::tr(g), |ui| {
ui.set_min_width(170.0);
for p in PROFILES.iter().filter(|p| p.group == g) {
item(ui, "group", p, &mut pick);
@@ -392,9 +391,9 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
if let Some(p) = profile(cur) {
let fav = app.session.profile_favorites.iter().any(|f| f == p.id);
let label = if fav {
- crate::i18n::tr_format!("Remove “{}” from Favorites", p.name)
+ crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::tr(p.name))
} else {
- crate::i18n::tr_format!("Add “{}” to Favorites", p.name)
+ crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::tr(p.name))
};
let r = ui.button(label);
register(ui.ctx(), "profileMenu:toggleFavorite", r.rect);
@@ -414,7 +413,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
let mut s = d.clone();
s.profile.id = p.id.to_string();
s.profile.amount = 100.0;
- app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s });
+ app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s });
}
if let Some(id) = pick.0 {
let _ = app.run("develop.profile", json!({"id": id}));
@@ -811,7 +810,9 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
ui.horizontal(|ui| {
ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label));
let current = matching_preset(&app.session, &d.curve);
- let r = crate::widgets::dropdown(ui, "curvePreset", current.as_deref().unwrap_or("Custom"), t.font(12.0), t.text);
+ let builtin = current.as_ref().is_none_or(|name| all_presets(&app.session).iter().any(|p| p.builtin && &p.name == name));
+ let label = crate::i18n::builtin_label(current.as_deref().unwrap_or("Custom"), builtin);
+ let r = crate::widgets::dropdown(ui, "curvePreset", label, t.font(12.0), t.text);
egui::Popup::menu(&r).show(|ui| {
ui.set_min_width(180.0);
let presets = all_presets(&app.session);
@@ -821,7 +822,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
user_seen = true;
ui.separator();
}
- let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), &p.name);
+ let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), crate::i18n::builtin_label(&p.name, p.builtin));
register(ui.ctx(), format!("curvePreset:{}", p.name), r.rect);
if r.clicked() {
let _ = app.run("curve.applyPreset", json!({"name": p.name}));
diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs
index 760abbfe0..f9fe79db8 100644
--- a/crates/ui-egui/src/panels/grid.rs
+++ b/crates/ui-egui/src/panels/grid.rs
@@ -150,7 +150,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
folder_header(app, ui, hr, &b, &ids, local, &cnt)
}
None => {
- let title = app.session.source.label(&app.session.catalog);
+ let title = crate::i18n::source_label(app.session.source, &app.session.catalog);
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
}
@@ -164,7 +164,11 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if !chips.is_empty() {
let body = match total {
Some(n) if n > 0 => {
- crate::i18n::tr_format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog), n = n)
+ crate::i18n::tr_format!(
+ "{n} photos in {} are hidden by the filters above",
+ crate::i18n::source_label(app.session.source, &app.session.catalog),
+ n = n
+ )
}
_ => "Remove a filter above, or choose Clear all".to_string(),
};
@@ -409,7 +413,7 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids:
p.rect_filled(r, 0.0, t.canvas);
p.hline(r.x_range(), r.bottom(), Stroke::new(1.0, t.divider));
}
- let g = p.layout_no_wrap(run.label.clone(), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label });
+ let g = p.layout_no_wrap(crate::i18n::date_group_label(&run.key, false), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label });
let x = r.left() + 8.0;
let gw = g.size().x;
p.galley(pos2(x, r.center().y - g.size().y / 2.0), g, t.text);
@@ -520,7 +524,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
.collect();
if parts.is_empty() { "—".to_string() } else { parts.join(" · ") }
}
- "date" => photo.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "No date".into()),
+ "date" => photo.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| crate::i18n::tr("No date").into()),
_ => photo.file_name.rsplit_once('.').map(|(n, _)| n.to_string()).unwrap_or(photo.file_name.clone()),
};
p.text(pos2(r.left() + 8.0, r.top() + 12.0), Align2::LEFT_CENTER, name, t.font(10.5), t.text_dim);
@@ -786,7 +790,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.separator();
ui.menu_button(crate::i18n::tr("Set Rating"), |ui| {
for r in 0..=5 {
- if ui.button(if r == 0 { "No Stars".to_string() } else { "★".repeat(r) }).clicked() {
+ if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() {
let _ = app.run("photo.rate", json!({"rating": r}));
}
}
diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs
index 7d7037435..760eefde8 100644
--- a/crates/ui-egui/src/panels/left.rs
+++ b/crates/ui-egui/src/panels/left.rs
@@ -120,14 +120,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("By Date"), t.semibold(13.5), t.text_label);
for g in app.caches.date_groups(&app.session.catalog).iter() {
// year → month → day; a click filters by that prefix, the triangle opens a level
- if date_row(app, ui, &g.year, &g.year, g.count, 0.0) {
+ if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) {
for (m, n) in &g.months {
- let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string();
+ let label = crate::i18n::date_group_label(m, true);
if date_row(app, ui, m, &label, *n, 16.0) {
for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) {
- let label = lightcraft_catalog::dates::group_label(d);
- // "Sunday, 20 September 2026" → "Sunday, 20"
- let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string();
+ let label = crate::i18n::date_group_label(d, true);
date_row(app, ui, d, &label, *n, 32.0);
}
}
diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs
index 20a095e03..0a7351bab 100644
--- a/crates/ui-egui/src/panels/presets.rs
+++ b/crates/ui-egui/src/panels/presets.rs
@@ -80,22 +80,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let row_h = if thumbs { 54.0 } else { 26.0 };
let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
- let mut groups: BTreeMap> = BTreeMap::new();
+ let mut groups: BTreeMap<(String, bool), Vec> = BTreeMap::new();
for p in &app.session.presets {
- groups.entry(p.group.clone()).or_default().push(p.clone());
+ groups.entry((p.group.clone(), p.builtin)).or_default().push(p.clone());
}
- for (g, items) in groups {
- let open_id = egui::Id::new(("preset-group", &g));
+ for ((g, builtin), items) in groups {
+ let open_id = egui::Id::new(("preset-group", &g, builtin));
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
- register(ui.ctx(), format!("presetGroup:{g}"), r);
+ let shared_group = app.session.presets.iter().any(|p| p.group == g && p.builtin != builtin);
+ register(ui.ctx(), if builtin || !shared_group { format!("presetGroup:{g}") } else { format!("presetGroup:user:{g}") }, r);
paint(
ui.painter(),
Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)),
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_label,
);
- ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, &g, t.semibold(13.0), t.text_label);
+ ui.painter().text(
+ pos2(r.left() + 36.0, r.center().y),
+ Align2::LEFT_CENTER,
+ crate::i18n::builtin_label(&g, builtin),
+ t.semibold(13.0),
+ t.text_label,
+ );
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
@@ -108,7 +115,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
continue;
}
for pr in items {
- let (pid, name, fav) = (pr.id.clone(), pr.name.clone(), pr.favorite);
+ let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
register(ui.ctx(), format!("preset:{pid}"), r);
let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid);
@@ -156,7 +163,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
let (builtin, group) = (pr.builtin, pr.group.clone());
resp.context_menu(|ui| {
- if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() {
+ if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() {
let _ = app.run("preset.favorite", json!({"id": pid}));
}
if builtin {
@@ -168,7 +175,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.clicked()
{
match app.run("preset.update", json!({"id": pid})) {
- Ok(_) => app.toast(ui.ctx(), format!("Updated “{name}”")),
+ Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)),
Err(e) => app.toast(ui.ctx(), e),
}
}
diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs
index 7633cdef6..ec4ddc57b 100644
--- a/crates/ui-egui/src/panels/profiles.rs
+++ b/crates/ui-egui/src/panels/profiles.rs
@@ -35,7 +35,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
let name = profile(&d.profile.id).map(|p| p.name).unwrap_or("Color");
ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Profile"), t.font(13.0), t.text_dim);
- ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, name, t.font(14.0), t.text_label);
+ ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr(name), t.font(14.0), t.text_label);
if d.profile.id != "lc.color" {
super::edit::control(app, ui, &d, "profile.amount", true);
ui.add_space(6.0);
@@ -60,7 +60,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_label,
);
- ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label);
+ ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label);
ui.painter().text(pos2(r.right() - 22.0, r.center().y), Align2::RIGHT_CENTER, list.len().to_string(), t.font(12.0), t.text_dim);
if resp.clicked() {
ui.data_mut(|m| m.insert_temp(open_id, !open));
@@ -90,7 +90,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
&& let Some(p) = profile(&saved_profile_id)
{
let s = with_profile(&d, p);
- app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s });
+ app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s });
}
} else {
ui.data_mut(|m| m.remove::<(PhotoId, String)>(last_hover_id));
@@ -129,7 +129,7 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id:
painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside);
}
let name_color = if applied { t.text } else { t.text_label };
- painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, p.name, t.font(12.0), name_color);
+ painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, crate::i18n::tr(p.name), t.font(12.0), name_color);
// favourite star (always shown when set, on hover otherwise)
let fav = app.session.profile_favorites.iter().any(|f| f == p.id);
let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0));
@@ -140,10 +140,10 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id:
paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE });
}
if resp.hovered() && !applied && !over_star {
- app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s.clone() });
+ app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s.clone() });
ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p.id.to_string())));
}
- let resp = resp.on_hover_text(format!("{} ({})", p.name, p.group));
+ let resp = resp.on_hover_text(format!("{} ({})", crate::i18n::tr(p.name), crate::i18n::tr(p.group)));
if resp.clicked() {
if resp.interact_pointer_pos().is_some_and(|q| star.contains(q)) {
let _ = app.run("profile.favorite", json!({"id": p.id}));
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index 1a07b5c6b..fcbb6abb6 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -48,7 +48,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
pub fn header(ui: &mut egui::Ui, title: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
- ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, title, t.semibold(15.0), t.text);
+ ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(15.0), t.text);
}
pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) {
@@ -69,7 +69,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.horizontal(|ui| {
ui.label(crate::i18n::tr("Aspect Ratio"));
let cur = d.crop.aspect.map(|(w, h)| format!("{} × {}", w as f64 / 100.0, h as f64 / 100.0)).unwrap_or_else(|| "Free".into());
- let r = ui.add(egui::Button::new(cur).frame(false));
+ let r = ui.add(egui::Button::new(crate::i18n::tr(&cur)).frame(false));
register(ui.ctx(), "button:cropAspect", r.rect);
egui::Popup::menu(&r).show(|ui| {
for (label, a) in [
@@ -84,7 +84,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
("16 × 9", "16x9"),
("16 × 10", "16x10"),
] {
- if ui.button(label).clicked() {
+ if ui.button(crate::i18n::tr(label)).clicked() {
let _ = app.run("crop.aspect", json!({"aspect": a}));
}
}
@@ -494,7 +494,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() {
let _ = app.run("dialog.captureTime", json!({}));
}
- let cap = p.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "—".into());
+ let cap = p.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| "—".into());
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate());
});
@@ -651,7 +651,7 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr
fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) {
let t = Tokens::get(ui.ctx());
- ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim));
+ ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim));
let id = egui::Id::new(("info-field", key));
let mut text: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string());
let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) };
@@ -830,7 +830,8 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false);
let named_n = p.versions.iter().filter(|v| !v.auto).count();
let items = [("Named", "named"), ("Auto", "auto")];
- let labels = [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), format!("Auto ({})", p.versions.len() - named_n)];
+ let labels =
+ [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), crate::i18n::tr_format!("Auto ({})", p.versions.len() - named_n)];
let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect();
if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) {
auto = i == 1;
@@ -840,7 +841,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect();
if shown.is_empty() {
let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." };
- ui.label(egui::RichText::new(msg).color(t.text_dim));
+ ui.label(egui::RichText::new(crate::i18n::tr(msg)).color(t.text_dim));
}
// newest first
for (i, v) in shown.into_iter().rev() {
@@ -907,6 +908,9 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
/// "Sep 30, 2026, 12:00 PM" from an ISO time.
fn short_time(iso: &str) -> String {
+ if crate::i18n::language() == crate::i18n::Language::ZhTw {
+ return crate::i18n::display_time(iso);
+ }
let long = lightcraft_catalog::dates::display_time(iso);
// "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped
let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t)));
@@ -935,7 +939,13 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.painter().rect_filled(r, 3.0, t.hover);
}
paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon);
- ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, &h.label, t.font(12.5), t.text_label);
+ ui.painter().text(
+ pos2(r.left() + 24.0, r.center().y),
+ Align2::LEFT_CENTER,
+ crate::i18n::history_label(&h.label, &app.session.presets),
+ t.font(12.5),
+ t.text_label,
+ );
if resp.clicked() {
let _ = app.run("history.restore", json!({"index": i}));
}
diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs
index a380090e1..5d2582e0b 100644
--- a/crates/ui-egui/src/panels/settings.rs
+++ b/crates/ui-egui/src/panels/settings.rs
@@ -91,7 +91,7 @@ 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);
+ choices(ui, "settingsLanguage", &crate::i18n::Language::ALL.map(|language| (language, language.name())), &mut app.ui.language);
crate::i18n::set_language(app.ui.language);
});
heading(ui, t, crate::i18n::tr("Library"));
diff --git a/docs/localization-zh-tw.md b/docs/localization-zh-tw.md
new file mode 100644
index 000000000..3b20b3757
--- /dev/null
+++ b/docs/localization-zh-tw.md
@@ -0,0 +1,46 @@
+# LightCraft 繁體中文(台灣)介面
+
+從「編輯 → 語言 → 繁體中文(台灣)」切換,或開啟「設定 → 一般 → 語言」選擇。
+macOS 的設定也可由「LightCraft → 設定…」或 `⌘,` 開啟。
+語言立即生效,並儲存在應用程式設定的 `ui.json`,下次啟動會沿用。
+英文、日文與繁體中文可隨時互相切換。
+
+繁中翻譯涵蓋 macOS 原生選單、程式內選單與子選單、鍵盤快速鍵說明、照片編輯、
+裁切、遮色片、匯入/匯出、設定、41 個內建編輯預設集與分類、內建描述檔、
+曲線與匯出預設集、歷史紀錄/版本面板、內建照片來源標題、日期群組與拍攝時間、
+照片庫開啟失敗提示及主要處理進度。
+切換介面不會變更操作指令 ID、照片檔名、使用者命名的相簿、預設集或中繼資料。
+未翻譯的技術錯誤、發行說明及部分新增文案仍使用英文。
+
+## 建置與字型
+
+使用既有 [craft-fonts](https://github.com/storytold/craft-fonts) 字型作為建置輸入:
+
+```sh
+CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
+```
+
+桌面版以現有日本語字型補足繁中文字形,不在此儲存庫新增字型檔。
+未指定 `CRAFT_FONTS_DIR` 的建置缺少 CJK 字形。
+Web 建置僅嵌入 BIZ UDPGothic Regular,尚未驗證完整繁中文字形覆蓋。
+
+## 自動化與維護
+
+`LIGHTCRAFT_LANGUAGE=zh-tw lightcraft-cli snapshot --demo -o zh-tw.png` 可繪製繁中畫面。
+接受 `zh-tw`、`zh-TW`、`zh-hant` 與 `zh-Hant`;儲存時統一使用 `zh-tw`。
+控制通道可切換語言:
+
+```json
+{"method":"ui.set","params":{"language":"zh-tw"}}
+{"method":"ui.menu.invoke","params":{"command":"app.language.traditionalChinese"}}
+```
+
+靜態文案位於 `crates/ui-egui/locales/zh-tw.json`,含變數的訊息位於
+`crates/ui-egui/locales/zh-tw-formats.json`。動態訊息由 Rust 在建置時檢查格式參數,
+須與 `ja-formats.json` 保持相同英文鍵。`{:.0}` 消耗英文複數字尾引數,繁中不顯示該字尾。
+
+```sh
+CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests
+```
+
+測試涵蓋翻譯目錄、指令/選單文字、語言切換、設定保存、資料名稱保持原樣及桌面字形。
diff --git a/docs/parity.md b/docs/parity.md
index fc94d08a7..c8693534d 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-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-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization-ja.md`, `docs/localization-zh-tw.md`, `cmd:app.language.traditionalChinese` | English/Japanese/Traditional Chinese UI with persisted language preference and existing CJK regular/bold fonts; menus, edit, crop/masks, settings, import/export and primary progress messages. Native macOS and in-window menus plus keyboard reference translated. Technical errors/release notes and other locales remain untranslated; full Traditional Chinese web glyph coverage unverified. |
| 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 | ⬜ | | |
From 51f0ef25ae7a2b5958e7ad83afcfabb646226f07 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 7 Oct 2026 05:41:28 -0400
Subject: [PATCH 21/30] Export: look up the library's symlinked originals once
per guard
The symlink fallback ran read_link on every library entry for each existing
target; a large library made every Overwrite export check do thousands of
syscalls. The guard now finds the links on first need and keeps them.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/originals.rs | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs
index 130d22a20..b3a140a2b 100644
--- a/crates/engine/src/originals.rs
+++ b/crates/engine/src/originals.rs
@@ -15,6 +15,8 @@ use lightcraft_catalog::{Catalog, Source};
pub struct OriginalGuard {
/// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …)
by_name: HashMap>,
+ /// The entries of `by_name` that are symlinks, found on the first check that needs them.
+ links: std::sync::OnceLock>,
}
impl OriginalGuard {
@@ -32,6 +34,7 @@ impl OriginalGuard {
/// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`).
pub fn add(&mut self, path: &Path, label: &str) {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return };
+ self.links = std::sync::OnceLock::new();
let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what));
put(name.clone(), path.to_path_buf(), format!("the original of {label}"));
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
@@ -60,9 +63,11 @@ impl OriginalGuard {
}
// An imported symlink can have a different name from its original. A write to
// that original would change what the library reads through the link. Only do
- // this fallback for existing targets that the name index did not protect.
- for (p, what) in self.by_name.values().flatten() {
- if std::fs::read_link(p).is_ok() && same_file(p, target) {
+ // this fallback for existing targets that the name index did not protect; the links are
+ // looked up once per guard, not once per target.
+ let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect());
+ for (p, what) in links {
+ if same_file(p, target) {
return Err(format!(
"{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)",
target.display()
From 3793e8fc3341dcf28ac29aefff8d93c93e8ebf43 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 7 Oct 2026 06:12:49 -0400
Subject: [PATCH 22/30] =?UTF-8?q?LR-LIB-PEOPLE:=20clip=20XMP=20face=20regi?=
=?UTF-8?q?ons=20to=20the=20photo,=20drop=20off-photo=20ones,=20read=20Rot?=
=?UTF-8?q?ation=20=CF=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A region parsed from mwg-rs:Regions is now intersected with the 0..1 frame and dropped when it
misses the photo, so a hostile or stale sidecar (x=1e5, a 1e5-wide box) can't park a box far
outside the image. Lightroom's mwg-rs:Rotation of ±π (Exif orientation 3) is mapped to the upright
frame like the quarter turns; mirrored orientations can't be expressed in MWG and are taken as
written.
Co-Authored-By: Claude Opus 5.5
---
crates/meta/src/xmp.rs | 68 +++++++++++++++++++++++++++++++++++++-----
1 file changed, 60 insertions(+), 8 deletions(-)
diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs
index f86876cae..b8a541096 100644
--- a/crates/meta/src/xmp.rs
+++ b/crates/meta/src/xmp.rs
@@ -414,16 +414,26 @@ fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option (cx / dw, cy / dh, w / dw, h / dh),
(true, None) => return None,
};
- // Lightroom Classic stores the box of a photo with Exif orientation 6 / 8 in the sensor's own frame
- // and writes the orientation as the region's `mwg-rs:Rotation` (−π/2 for 6, +π/2 for 8; 0 for 1)
- // while `AppliedToDimensions` names the upright size. Turn it into the upright frame.
+ // Lightroom Classic stores the box of a photo with Exif orientation 3 / 6 / 8 in the sensor's own
+ // frame and writes the orientation as the region's `mwg-rs:Rotation` (π for 3, −π/2 for 6, +π/2 for
+ // 8; 0 for 1) while `AppliedToDimensions` names the upright size. Turn it into the upright frame.
+ // MWG has no way to say "mirrored", so orientations 2/4/5/7 can't be told apart from their unmirrored
+ // twins here; boxes on mirrored photos are taken as written (see LR-LIB-PEOPLE in docs/parity.md).
let rotation = item.field("mwg-rs:Rotation").and_then(XmpValue::text).and_then(parse_number).filter(|r| r.is_finite());
- let quarter = std::f64::consts::FRAC_PI_2;
+ let (quarter, half) = (std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
let (cx, cy, w, h) = match rotation {
Some(r) if (r + quarter).abs() < 0.05 => (1.0 - cy, cx, h, w),
Some(r) if (r - quarter).abs() < 0.05 => (cy, 1.0 - cx, h, w),
+ Some(r) if (r.abs() - half).abs() < 0.05 => (1.0 - cx, 1.0 - cy, w, h),
_ => (cx, cy, w, h),
};
+ // Keep only the part of the box inside the photo: a region hanging off an edge (common for faces
+ // cut by the frame) is clipped, and one entirely outside it (hostile or stale) is dropped, so every
+ // stored rect is within 0..1 and non-empty.
+ let rect = Rect::from_center(Point::new(cx, cy), w, h).intersect(&Rect::UNIT);
+ if rect.is_empty() {
+ return None;
+ }
let name = item.field("mwg-rs:Name").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty());
let description = item.field("mwg-rs:Description").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty());
let kind = match item.field("mwg-rs:Type").and_then(XmpValue::text).unwrap_or("") {
@@ -433,7 +443,7 @@ fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option RegionKind::BarCode,
t => RegionKind::Other(t.to_string()),
};
- Some(Region { rect: Rect::from_center(Point::new(cx, cy), w, h), kind, name, description })
+ Some(Region { rect, kind, name, description })
}
fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String {
@@ -883,8 +893,9 @@ mod tests {
assert_eq!(regions[2].description.as_deref(), Some("Good boy"));
}
- /// Lightroom Classic writes a portrait photo's (Exif orientation 6 / 8) box in the sensor's frame and
- /// records the orientation as `mwg-rs:Rotation` (−π/2 / +π/2); the reader returns the upright frame.
+ /// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame
+ /// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the
+ /// upright frame.
#[test]
fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() {
let x = r#"
@@ -903,16 +914,24 @@ mod tests {
+
+
+
+
+
+
"#;
let regions = parse_xmp(x).unwrap().metadata.regions;
- assert_eq!(regions.len(), 3, "{regions:?}");
+ assert_eq!(regions.len(), 5, "{regions:?}");
let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9);
assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect);
assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect);
assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect);
+ assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect);
+ assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect);
}
/// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an
@@ -975,6 +994,39 @@ mod tests {
assert_eq!(regions[0].kind, RegionKind::Face);
}
+ /// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a
+ /// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view
+ /// would then draw or crop far outside the image.
+ #[test]
+ fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() {
+ let x = r#"
+
+
+
+
+ Far right
+ Far left
+ Touching edge
+ Huge
+ Corner
+
+
+
+ "#;
+ let regions = parse_xmp(x).unwrap().metadata.regions;
+ let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect();
+ assert_eq!(names, ["Huge", "Corner"], "{regions:?}");
+ for r in ®ions {
+ let q = r.rect;
+ assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}");
+ }
+ assert_eq!(regions[0].rect, Rect::UNIT);
+ let c = regions[1].rect;
+ assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}");
+ }
+
/// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords).
#[test]
fn regions_merge_like_keywords() {
From 313c555b5040aea97e3a67b4f5a522418ddaec98 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 7 Oct 2026 06:13:44 -0400
Subject: [PATCH 23/30] 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 600997fdd..ebdfa0478 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 a1e9f86c2..c30f92ea0 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 3ebd08d20..5cac9dfef 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 55f52d523..92ac47ee0 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 9dfa998e8..bcfb8a8dd 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 f643c2aa8..2da811bb4 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 da3ff77df..b846df7a8 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 60a864977987fae7734826203c566273ab37d48d Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 7 Oct 2026 06:16:02 -0400
Subject: [PATCH 24/30] LR-LIB-PEOPLE: leave empty face regions out of the
catalog JSON
Co-Authored-By: Claude Opus 5.5
---
crates/catalog/src/model.rs | 6 ++++--
crates/catalog/src/tests.rs | 20 ++++++++++++++++++++
2 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs
index ec8d28d17..9248b2206 100644
--- a/crates/catalog/src/model.rs
+++ b/crates/catalog/src/model.rs
@@ -183,8 +183,10 @@ pub struct Meta {
pub copyright_url: String,
pub creator: String,
pub keywords: Vec,
- /// Face/pet/focus regions read from XMP (MWG-RS). Read-only today; nothing in
- /// LightCraft writes regions yet.
+ /// Face/pet/focus regions read from XMP (MWG-RS), on the upright (EXIF-oriented) photo.
+ /// Removing or resizing one edits the catalog only; LightCraft never writes regions to XMP.
+ /// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub regions: Vec,
}
diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs
index 7897f7972..6f76b694e 100644
--- a/crates/catalog/src/tests.rs
+++ b/crates/catalog/src/tests.rs
@@ -360,3 +360,23 @@ fn people_from_named_face_regions() {
);
assert_eq!(names(Filter { rating: 5, ..Default::default() }), vec![], "nobody in the filtered photos");
}
+
+/// Most photos have no regions: the field is left out of the catalog JSON then, and a catalog written
+/// before regions existed (no `regions` key) reads with none.
+#[test]
+fn empty_regions_are_not_serialized_and_default_when_missing() {
+ let m = Meta { keywords: vec!["k".into()], ..Default::default() };
+ let v = serde_json::to_value(&m).unwrap();
+ assert!(v.get("regions").is_none(), "{v}");
+ let back: Meta = serde_json::from_value(v).unwrap();
+ assert!(back.regions.is_empty());
+ let mut with = m.clone();
+ with.regions.push(lightcraft_meta::Region {
+ rect: lightcraft_meta::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 },
+ kind: lightcraft_meta::RegionKind::Face,
+ name: Some("A".into()),
+ description: None,
+ });
+ let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
+ assert_eq!(back, with);
+}
From 87a58b4183839ebcc4874314027b534a5841f2bc Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 7 Oct 2026 06:17:06 -0400
Subject: [PATCH 25/30] 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 92ac47ee0..5a7e0f304 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 92910324d..9f63e1088 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 b846df7a8..3eb5efcaa 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 d81f60f90..fc9226782 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 3e59a2013..321e3413c 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 8c253b28ada12368219aa4765cbc00b9f99dfc61 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 7 Oct 2026 06:19:45 -0400
Subject: [PATCH 26/30] LR-LIB-PEOPLE: a sidecar that states mwg-rs:Regions
replaces the photo's regions, even when empty
Re-reading a sidecar whose region list was emptied by a region-aware app now clears the stale
regions. A sidecar without mwg-rs:Regions at all (an app that doesn't do regions) says nothing about
them and the photo's regions are kept.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/sidecar.rs | 7 +++++-
crates/engine/src/tests_xmp.rs | 43 ++++++++++++++++++++++++++++++++++
2 files changed, 49 insertions(+), 1 deletion(-)
diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs
index f57ac16a5..7e9249489 100644
--- a/crates/engine/src/sidecar.rs
+++ b/crates/engine/src/sidecar.rs
@@ -134,7 +134,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result
+
+ "#,
+ )
+ .unwrap();
+ s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
+ let p = s.catalog.photo(id).unwrap();
+ assert_eq!(p.rating, 3, "the sidecar was read");
+ assert_eq!(p.meta.regions.len(), 1, "a sidecar without mwg-rs:Regions keeps the photo's regions");
+ // a region-aware app removed every region: the sidecar says so, and the catalog follows
+ let emptied = MWG_REGIONS.replace(
+ r#"
+
+
+
+ "#,
+ "",
+ );
+ assert_ne!(emptied, MWG_REGIONS);
+ std::fs::write(src.join("portrait.xmp"), emptied).unwrap();
+ s.execute("photo.readMetadataFromFile", &json!({})).unwrap();
+ assert!(s.catalog.photo(id).unwrap().meta.regions.is_empty(), "an emptied region list clears stale regions");
+ // undo brings them back
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1);
+ let _ = std::fs::remove_dir_all(&src);
+}
+
fn synthetic_dng(xmp: &str) -> Vec {
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
}
From 269934caca4e8a81200341e1512d963d1f4c2e21 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 7 Oct 2026 06:19:50 -0400
Subject: [PATCH 27/30] 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 33b5c8676..d3c9bdb7e 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 5a7e0f304..c208ecb6e 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 90b72967d..5fb0bd7b0 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 321e3413c..fc87ecc38 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"]);
}
From 4ded49ecd9b2b2a43f75d61adb9401a3fe92782d Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 7 Oct 2026 06:21:04 -0400
Subject: [PATCH 28/30] LR-LIB-PEOPLE: face boxes and People close-ups follow
Rotate Left/Right
Regions are stored on the upright (EXIF-oriented) photo, but the loupe's normalized frame and the
close-up crop include the user's orientation. The loupe overlay now maps each box into that frame
(and a resize drag back out of it), and face_job maps the face and swaps the photo's size for a
quarter turn. Flips need nothing: they are applied after the normalized frame. Adds
Orientation::inverse and Orientation::map_norm_rect.
Co-Authored-By: Claude Opus 5.5
---
crates/engine/src/media.rs | 5 +++++
crates/engine/src/tests.rs | 22 +++++++++++++++++++++
crates/geom/src/lib.rs | 30 +++++++++++++++++++++++++++++
crates/ui-egui/src/panels/detail.rs | 19 +++++++++++++-----
4 files changed, 71 insertions(+), 5 deletions(-)
diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs
index 789c880f2..c5860c495 100644
--- a/crates/engine/src/media.rs
+++ b/crates/engine/src/media.rs
@@ -711,7 +711,12 @@ impl crate::Session {
/// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content.
pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option {
let p = self.catalog.photo(id)?.clone();
+ // `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the
+ // user's Rotate Left/Right, so carry the box (and the photo's size) over to it.
+ let orient = p.develop.orientation;
+ let face = orient.map_norm_rect(face);
let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1)));
+ let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) };
let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7;
// finite, within the photo, and never wider than its short side (so the clamps below are valid)
let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) };
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 9becd6c62..2837d6726 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -761,3 +761,25 @@ fn face_job_crops_a_square_inside_the_photo() {
assert!((((r.x0 + r.x1) / 2.0) - 0.45).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.375).abs() < 1e-9, "{r:?}");
assert!(s.face_job(lightcraft_catalog::PhotoId(u64::MAX), faces[0], 256).is_none());
}
+
+/// Regions are on the upright photo; after Rotate Right the close-up still frames the face: the box
+/// is carried into the rotated frame the crop lives in, and the crop stays square in rotated pixels.
+#[test]
+fn face_job_follows_rotate_right() {
+ use lightcraft_geom::Rect;
+ let mut s = demo();
+ let id = s.active().unwrap();
+ let (w, h) = {
+ let p = s.catalog.photo(id).unwrap();
+ (f64::from(p.width), f64::from(p.height))
+ };
+ // a small face left of centre on the upright photo
+ let face = Rect { x0: 0.30, y0: 0.45, x1: 0.35, y1: 0.50 };
+ s.execute("photo.rotateRight", &json!({})).unwrap();
+ let r = s.face_job(id, face, 256).unwrap().settings.crop.geometry.rect;
+ // a quarter turn clockwise puts the left of the photo at the top: centre (0.325, 0.475) → (0.525, 0.325)
+ assert!((((r.x0 + r.x1) / 2.0) - 0.525).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.325).abs() < 1e-9, "{r:?}");
+ // the rotated photo is h × w pixels
+ let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w);
+ assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}");
+}
diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs
index 509285aab..327c60fcc 100644
--- a/crates/geom/src/lib.rs
+++ b/crates/geom/src/lib.rs
@@ -332,6 +332,23 @@ impl Orientation {
// flipH ∘ rot(t) = rot(-t) ∘ flipH
Orientation::from_parts(!f, (4 - t) % 4)
}
+ /// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns
+ /// are their own inverses; quarter turns swap.
+ pub fn inverse(self) -> Orientation {
+ match self {
+ Orientation::Rotate90 => Orientation::Rotate270,
+ Orientation::Rotate270 => Orientation::Rotate90,
+ o => o,
+ }
+ }
+ /// Map a rectangle in normalized (0..1) coordinates of the stored image to normalized coordinates
+ /// of the oriented one (e.g. a face region given on the upright photo → the frame after the user's
+ /// Rotate Left/Right).
+ pub fn map_norm_rect(self, r: Rect) -> Rect {
+ let (ax, ay) = self.map(r.x0, r.y0, 1.0, 1.0);
+ let (bx, by) = self.map(r.x1, r.y1, 1.0, 1.0);
+ Rect::from_points(Point::new(ax, ay), Point::new(bx, by))
+ }
/// Map a point in stored pixel space (w×h stored dims) to oriented space.
pub fn map(self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) {
let (flip, t) = self.to_parts();
@@ -417,6 +434,19 @@ mod tests {
assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90);
}
+ #[test]
+ fn orientation_inverse_and_norm_rect() {
+ let r = Rect::new(0.1, 0.2, 0.3, 0.6);
+ for v in 1..=8 {
+ let o = Orientation::from_exif(v);
+ let back = o.inverse().map_norm_rect(o.map_norm_rect(r));
+ assert!([back.x0 - r.x0, back.y0 - r.y0, back.x1 - r.x1, back.y1 - r.y1].iter().all(|d| d.abs() < 1e-12), "{o:?}: {back:?}");
+ }
+ // a quarter turn clockwise: the left of the upright photo becomes its top
+ let q = Orientation::Rotate90.map_norm_rect(r);
+ assert!([q.x0 - 0.4, q.y0 - 0.1, q.x1 - 0.8, q.y1 - 0.3].iter().all(|d| d.abs() < 1e-12), "{q:?}");
+ }
+
proptest::proptest! {
#[test]
fn affine_inverse_prop(a in -3.0f64..3.0, sx in 0.1f64..5.0, sy in 0.1f64..5.0, tx in -50.0f64..50.0, px in -20.0f64..20.0, py in -20.0f64..20.0) {
diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs
index d50ed5978..cde3efdb1 100644
--- a/crates/ui-egui/src/panels/detail.rs
+++ b/crates/ui-egui/src/panels/detail.rs
@@ -338,7 +338,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
filter_pill(app, ui, canvas);
}
if app.ui.face_boxes {
- match region_overlay(ui, &p, &map, &photo) {
+ match region_overlay(ui, &p, &map, &photo, d.orientation) {
Some(RegionEdit::Remove(index)) => {
let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index}));
}
@@ -458,8 +458,15 @@ const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [
/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows
/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once,
/// on release (one undo step); the × reports the region to remove. Both are catalog-only edits:
-/// LightCraft doesn't write regions to XMP.
-fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) -> Option {
+/// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo;
+/// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes.
+fn region_overlay(
+ ui: &egui::Ui,
+ p: &egui::Painter,
+ map: &CanvasMap,
+ photo: &lightcraft_catalog::Photo,
+ orient: lightcraft_geom::Orientation,
+) -> Option {
let t = Tokens::get(p.ctx());
let clip = p.clip_rect();
let pointer = ui.input(|i| i.pointer.hover_pos());
@@ -469,7 +476,7 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig
let mut edit = None;
for (index, r) in photo.meta.regions.iter().enumerate() {
let dragging = live.filter(|(i, _)| *i == index);
- let norm = dragging.map_or(r.rect, |(_, n)| n);
+ let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n));
let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1)));
// white with a black keyline just outside it, so the box shows on any background
p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside);
@@ -500,12 +507,14 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig
n.max.y = pp.y.max(n.min.y + 12.0);
}
let (a, b) = (map.norm(n.min), map.norm(n.max));
- let new = lightcraft_geom::Rect {
+ let shown = lightcraft_geom::Rect {
x0: a.x.min(b.x).clamp(0.0, 1.0),
y0: a.y.min(b.y).clamp(0.0, 1.0),
x1: a.x.max(b.x).clamp(0.0, 1.0),
y1: a.y.max(b.y).clamp(0.0, 1.0),
};
+ // back to the upright frame the region is stored in
+ let new = orient.inverse().map_norm_rect(shown);
ui.data_mut(|d| d.insert_temp(drag_key, (index, new)));
ui.ctx().request_repaint();
}
From 862c1f253a167d5d46f1c9da110345beecf57178 Mon Sep 17 00:00:00 2001
From: Brandon Thomas
Date: Wed, 7 Oct 2026 06:21:27 -0400
Subject: [PATCH 29/30] LR-LIB-PEOPLE: document MWG face regions in xmp-interop
and the parity row
Co-Authored-By: Claude Opus 5.5
---
docs/parity.md | 2 +-
docs/xmp-interop.md | 20 ++++++++++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/docs/parity.md b/docs/parity.md
index f9850b6e5..dd5ceaf88 100644
--- a/docs/parity.md
+++ b/docs/parity.md
@@ -145,7 +145,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle |
| LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels |
| LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop |
-| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back |
+| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back |
| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack |
| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M |
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; no confirmation dialog, no auto-purge after N days, no "Empty" |
diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md
index 017f35691..003b8774d 100644
--- a/docs/xmp-interop.md
+++ b/docs/xmp-interop.md
@@ -70,6 +70,26 @@ library share a stem, the stem sidecar belongs to one of them — a raw first, o
the others write and read `.xmp` (`IMG_0001.JPG.xmp`), falling back to the stem sidecar for reading when they
have none. So saving one photo never overwrites the other's metadata (`Session::sidecar_naming`).
+## Face regions (MWG)
+
+LightCraft reads face, pet, focus and barcode regions in the Metadata Working Group's region schema
+(`mwg-rs:Regions`, `http://www.metadataworkinggroup.com/schemas/regions/`), as Lightroom, digiKam, Picasa and others
+write them, from sidecars and from XMP embedded in the file. Named faces become people in the People view
+(LR-LIB-PEOPLE in [`parity.md`](parity.md)), and every region is drawn as a box in the loupe.
+
+- **Read only.** LightCraft never writes `mwg-rs:Regions`; saving a sidecar keeps another application's regions byte
+ for byte. Removing or resizing a box in the loupe changes the library only.
+- **Areas**: `stArea:x`/`y` are the box's centre, `w`/`h` its size, normalized to the photo (`stArea:unit="pixel"`
+ areas are divided by `mwg-rs:AppliedToDimensions`, and dropped without it). Boxes are clipped to the photo; one
+ that misses it entirely, or has a non-finite, zero or negative size, is skipped (the rest of the list is kept).
+- **Orientation**: regions are stored on the upright (EXIF-oriented) photo. A `mwg-rs:Rotation` of −π/2, +π/2 or ±π
+ (how Lightroom marks a box given in the sensor's frame for Exif orientation 6, 8 or 3) is turned back into the
+ upright frame; MWG can't say "mirrored", so boxes on mirrored photos (orientations 2, 4, 5, 7) are taken as written.
+ Boxes follow LightCraft's own Rotate Left/Right and flips.
+- **Re-reading**: a sidecar that has `mwg-rs:Regions` — even with an empty list — replaces the photo's regions, so
+ regions removed in another application go away here too. A sidecar without `mwg-rs:Regions` (an application that
+ doesn't do regions) leaves the photo's regions as they are.
+
## Reading `crs:` develop fields
Many raw developers store edits as `crs:` properties (`http://ns.adobe.com/camera-raw-settings/1.0/`) in sidecars, in
From ca0c5698639e80ea2ccabe5310fcc87395778840 Mon Sep 17 00:00:00 2001
From: sakamoto824
Date: Wed, 7 Oct 2026 06:54:19 -0400
Subject: [PATCH 30/30] LR-BEHAV-LOCALIZE: Traditional Chinese (Taiwan) UI,
stock preset and date localisation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Port of PR #198 onto the language table:
- Traditional Chinese (Taiwan) catalogs as a table entry: code zh-hant, name
繁體中文(台灣), script Hant; zh-TW / zh-HK / zh-Hant tags reach it; command
app.language.traditionalChinese
- display-only helpers: builtin_label (stock preset/profile names), history_label
(generated history steps, user preset names verbatim), source_label (library
headings, album names verbatim), date_group_label / display_time
- menu, dialog and panel strings (profiles, presets, curves, crop, versions,
history, grid, date sidebar, About links) go through the catalogs
- a language command and ui.set apply the language at once; a native-menu test
checks that every language switch rebuilds the menu titles
Adapted while porting (Claude):
- date headings and capture times are localised for every language from its
*-formats.json date patterns, not only for zh-TW; ja and zh-hans gain the date
patterns, weekday names and "Unknown Date", and zh-hans the new menu messages
(ja's come from the PR)
- plain catalogs may cover different messages (English fallback); the test
reports the gaps instead of failing. The test requiring every engine command
label to be translated is dropped
- preset groups stay grouped by name: a user preset in a stock-named group is
not split into a separate group (the group header is translated when it holds
stock presets); the PR's split was a side effect of labelling
- no Traditional Chinese face in craft-fonts yet: Hant prefers the Simplified
Chinese face (which carries the Traditional characters) over the Japanese ones
- zh-hant translations for the messages main added since the PR branched
Co-authored-by: Codex
Co-Authored-By: Claude Opus 5.5
---
README.md | 3 +
ROADMAP.md | 6 +-
apps/lightcraft/src/native_menu.rs | 21 +
crates/engine/src/fonts.rs | 11 +-
crates/ui-egui/locales/ja-formats.json | 17 +-
crates/ui-egui/locales/ja.json | 10 +-
crates/ui-egui/locales/zh-hans-formats.json | 17 +-
crates/ui-egui/locales/zh-hans.json | 10 +-
crates/ui-egui/locales/zh-hant-formats.json | 122 ++
crates/ui-egui/locales/zh-hant.json | 1248 +++++++++++++++++++
crates/ui-egui/src/control.rs | 1 +
crates/ui-egui/src/i18n.rs | 234 +++-
crates/ui-egui/src/menus.rs | 5 +
crates/ui-egui/src/panels/dialogs.rs | 10 +-
crates/ui-egui/src/panels/edit.rs | 21 +-
crates/ui-egui/src/panels/grid.rs | 14 +-
crates/ui-egui/src/panels/left.rs | 8 +-
crates/ui-egui/src/panels/presets.rs | 17 +-
crates/ui-egui/src/panels/profiles.rs | 12 +-
crates/ui-egui/src/panels/right.rs | 27 +-
docs/localization-zh-hans.md | 2 +-
docs/localization-zh-hant.md | 46 +
docs/localization.md | 16 +-
docs/parity.md | 4 +-
24 files changed, 1804 insertions(+), 78 deletions(-)
create mode 100644 crates/ui-egui/locales/zh-hant-formats.json
create mode 100644 crates/ui-egui/locales/zh-hant.json
create mode 100644 docs/localization-zh-hant.md
diff --git a/README.md b/README.md
index 654c8e866..9692b6360 100644
--- a/README.md
+++ b/README.md
@@ -344,6 +344,9 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
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).
+**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
+Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
+
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
diff --git a/ROADMAP.md b/ROADMAP.md
index 559ad525a..fdfbec0cb 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; Chinese/Japanese/English UI (see docs/localization.md); remaining technical errors and date formats; 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; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; 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 ✅; Chinese/Japanese/English localisation 🟡; accessibility and further locales ⬜) |
+| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
@@ -194,7 +194,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
## Chinese and Japanese interfaces, and text watermarks
-English, Simplified Chinese and Japanese interface languages are persisted in UI state, and the
+English, Simplified Chinese, Traditional Chinese (Taiwan) 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:
diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs
index d3c9bdb7e..557c9abfb 100644
--- a/apps/lightcraft/src/native_menu.rs
+++ b/apps/lightcraft/src/native_menu.rs
@@ -419,6 +419,27 @@ impl NativeMenu {
mod tests {
use super::*;
+ /// The menu model's titles stay English, so only the language can tell the native menu to
+ /// rebuild its titles (and the macOS app menu): every language switch — also between two CJK
+ /// languages — must change the structure key.
+ #[test]
+ fn switching_language_rebuilds_native_menu_structure() {
+ use lightcraft_ui_egui::i18n::{Locale, set_language};
+ let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
+ set_language(Locale::En);
+ let bar = menu_bar(&app);
+ let mut seen = std::collections::BTreeSet::new();
+ for language in Locale::ALL {
+ set_language(*language);
+ assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
+ }
+ set_language(Locale::ZhHant);
+ assert_eq!(label_text("File"), "檔案");
+ assert_eq!(label_text("Settings…"), "設定…");
+ assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
+ set_language(Locale::En);
+ }
+
#[test]
fn shortcuts_map_to_accelerators() {
assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)));
diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs
index c028ba53e..97a830639 100644
--- a/crates/engine/src/fonts.rs
+++ b/crates/engine/src/fonts.rs
@@ -26,6 +26,13 @@ impl CraftFont {
pub fn is_mincho(&self) -> bool {
self.family.contains("Mincho")
}
+
+ /// Whether the font can stand in for `script`: it is meant for it, or — until craft-fonts has a
+ /// Traditional Chinese face — it is a Simplified Chinese face, which carries the Traditional
+ /// characters too (with mainland glyph forms; still closer than the Japanese faces).
+ pub fn serves(&self, script: &str) -> bool {
+ self.covers(script) || (script == "Hant" && self.covers("Hans"))
+ }
}
/// The craft-fonts faces for Japanese, in `fonts`' order (empty without craft-fonts).
@@ -55,7 +62,7 @@ pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Ve
(
// 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.serves(script),
font.scripts.len() != 1,
font.family != "BIZ UDPGothic",
font.style != style,
@@ -99,6 +106,8 @@ mod tests {
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"));
+ // No Traditional Chinese face yet: the Chinese face stands in before the Japanese ones.
+ assert_eq!(first("Hant"), Some("Noto Sans CJK SC"));
// 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/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json
index 354205c8d..1944d7b6a 100644
--- a/crates/ui-egui/locales/ja-formats.json
+++ b/crates/ui-egui/locales/ja-formats.json
@@ -103,5 +103,20 @@
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚",
" ({} in Recently Deleted)": "(うち{}枚は最近削除した写真)",
"Already in the library: {} photo{} selected in All Photos": "ライブラリに登録済み:すべての写真で{}枚を選択しました{:.0}",
- "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し"
+ "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し",
+ "Auto ({})": "自動 ({})",
+ "Build 1:1 Previews ({scope})": "1:1プレビューを作成({scope})",
+ "Build Smart Previews ({scope})": "スマートプレビューを作成({scope})",
+ "Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})",
+ "Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})",
+ "Label Set: {name}": "ラベルセット:{name}",
+ "Quick Develop: {name}": "クイック現像: {name}",
+ "Reset {name}": "{name}をリセット",
+ "Updated “{name}”": "「{name}」を更新しました",
+ "{month}": "{month}月",
+ "{month} {year}": "{year}年{month}月",
+ "{verb} {n} Photo{}": "{n}枚を{verb}{:.0}",
+ "{weekday}, {day}": "{day}日({weekday})",
+ "{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})",
+ "{year}": "{year}年"
}
diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json
index 9c09bbb91..f131d0b46 100644
--- a/crates/ui-egui/locales/ja.json
+++ b/crates/ui-egui/locales/ja.json
@@ -1019,5 +1019,13 @@
"Back Up Library…": "ライブラリをバックアップ…",
"Restore Library from Backup…": "バックアップからライブラリを復元…",
"In Recently Deleted": "最近削除した写真内",
- "Reload from Disk": "ディスクから再読み込み"
+ "Reload from Disk": "ディスクから再読み込み",
+ "Monday": "月曜日",
+ "Tuesday": "火曜日",
+ "Wednesday": "水曜日",
+ "Thursday": "木曜日",
+ "Friday": "金曜日",
+ "Saturday": "土曜日",
+ "Sunday": "日曜日",
+ "Unknown Date": "日付不明"
}
diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json
index c30f92ea0..c3713eb18 100644
--- a/crates/ui-egui/locales/zh-hans-formats.json
+++ b/crates/ui-egui/locales/zh-hans-formats.json
@@ -103,5 +103,20 @@
"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} · 右键单击 ▸ 恢复,或永久删除后重新导入"
+ "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入",
+ "Auto ({})": "自动({})",
+ "Build 1:1 Previews ({scope})": "生成 1:1 预览({scope})",
+ "Build Smart Previews ({scope})": "生成智能预览({scope})",
+ "Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})",
+ "Discard Smart Previews ({scope})": "丢弃智能预览({scope})",
+ "Label Set: {name}": "标签集:{name}",
+ "Quick Develop: {name}": "快速编辑:{name}",
+ "Reset {name}": "重置{name}",
+ "Updated “{name}”": "已更新“{name}”",
+ "{verb} {n} Photo{}": "{verb} {n} 张照片{:.0}",
+ "{year}": "{year}年",
+ "{month}": "{month}月",
+ "{month} {year}": "{year}年{month}月",
+ "{weekday}, {day}": "{day}日 {weekday}",
+ "{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}"
}
diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json
index 5cac9dfef..7dc4d7c3b 100644
--- a/crates/ui-egui/locales/zh-hans.json
+++ b/crates/ui-egui/locales/zh-hans.json
@@ -1019,5 +1019,13 @@
"Back Up Library…": "备份图库…",
"Restore Library from Backup…": "从备份恢复图库…",
"In Recently Deleted": "在“最近删除”中",
- "Reload from Disk": "从磁盘重新读取"
+ "Reload from Disk": "从磁盘重新读取",
+ "Monday": "星期一",
+ "Tuesday": "星期二",
+ "Wednesday": "星期三",
+ "Thursday": "星期四",
+ "Friday": "星期五",
+ "Saturday": "星期六",
+ "Sunday": "星期日",
+ "Unknown Date": "未知日期"
}
diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json
new file mode 100644
index 000000000..816936c0f
--- /dev/null
+++ b/crates/ui-egui/locales/zh-hant-formats.json
@@ -0,0 +1,122 @@
+{
+ " · {skipped} skipped (file exists)": " · 已略過 {skipped} 個(檔案已存在)",
+ " · {} duplicate{} skipped": " · 已略過 {} 張重複照片{:.0}",
+ " · {} failed: {}": " · {} 個失敗:{}",
+ " · {} moved": " · 已移動 {} 個",
+ " · {} not readable": " · {} 個無法讀取",
+ ", {failed} file{} not readable": ",{failed} 個檔案無法讀取{:.0}",
+ "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 “{}”": "已將 {n} 張照片加入「{1}」{0:.0}",
+ "Adding photos… {} of {}": "正在新增照片… {}/{}",
+ "All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。",
+ "Auto ({})": "自動({})",
+ "Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片",
+ "Automatic ({} MB)": "自動({} MB)",
+ "Build 1:1 Previews ({scope})": "建立 1:1 預覽({scope})",
+ "Build Smart Previews ({scope})": "建立智慧型預覽({scope})",
+ "Build Standard-Sized Previews ({scope})": "建立標準尺寸預覽({scope})",
+ "Click {}: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}」",
+ "Discard Smart Previews ({scope})": "捨棄智慧型預覽({scope})",
+ "Duplicate \"{label}\"": "複製「{label}」",
+ "Example: {example}": "範例:{example}",
+ "Exported {n} photo{}": "已匯出 {n} 張照片{:.0}",
+ "Exported {ok} of {total} photo{}": "已匯出 {ok}/{total} 張照片{:.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}",
+ "Import cancelled · {} photo{} added": "已取消匯入 · 已加入 {} 張照片{:.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}",
+ "Label Set: {name}": "標籤集:{name}",
+ "Matches: {}": "符合條件:{}",
+ "Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%",
+ "Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?",
+ "Named ({named_n})": "已命名({named_n})",
+ "Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定",
+ "Preset: {name}": "預設集:{name}",
+ "Preview only — editing the camera's embedded JPEG ({})": "僅供預覽 — 正在編輯相機內嵌的 JPEG({})",
+ "Preview only — {}": "僅供預覽 — {}",
+ "Profile: {}": "描述檔:{}",
+ "Proof Copy ({})": "校樣副本({})",
+ "Proof Preview · {}": "校樣預覽 · {}",
+ "Quick Develop · {n} photos": "快速顯像 · {n} 張照片",
+ "Quick Develop: {name}": "快速顯像:{name}",
+ "Rated {}": "評分:{}",
+ "Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}",
+ "Reading photos… {} of {}": "正在讀取照片… {}/{}",
+ "Redo {}": "重做 {}",
+ "Remove “{}” from Favorites": "將「{}」從喜好項目移除",
+ "Rename {n} Photos…": "重新命名 {n} 張照片…",
+ "Rename “{name}”": "重新命名「{name}」",
+ "Rendering on the CPU: {why}": "使用 CPU 算繪:{why}",
+ "Reset {name}": "重設{name}",
+ "Reset {} Channel": "重設 {} 通道",
+ "Saved export preset “{}”": "已儲存匯出預設集「{}」",
+ "Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}",
+ "Showing {n} of {}": "顯示 {n}/{} 張",
+ "Smart album: {}": "智慧型相簿:{}",
+ "Undo {}": "復原 {}",
+ "Updated “{name}”": "已更新「{name}」",
+ "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {} — 以純 Rust 獨立實作的照片管理與 RAW 顯像程式。",
+ "Version: {}": "版本:{}",
+ "none (CPU rendering): {why}": "無(使用 CPU 算繪):{why}",
+ "the {n} selected photos": "選取的 {n} 張照片",
+ "the {} photos in view": "目前顯示的 {} 張照片",
+ "these {count} photos": "這 {count} 張照片",
+ "{count} smart preview{} · {:.1} MB": "{count} 張智慧型預覽{:.0} · {:.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": "{matching}/{t} 張照片",
+ "{matching} photos": "{matching} 張照片",
+ "{mode} spot {} of {}": "{mode}斑點:第 {}/{} 個",
+ "{month}": "{month}月",
+ "{month} {year}": "{year}年{month}月",
+ "{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}\nLightCraft 會自動重試;現在結束程式會遺失{}。",
+ "{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} · 隨照片變更自動更新",
+ "{sel_n} selected · {counted}": "已選取 {sel_n} 張 · {counted}",
+ "{stage}… {:.0}%": "{stage}… {:.0}%",
+ "{verb} {n} Photo{}": "{verb} {n} 張照片{:.0}",
+ "{weekday}, {day}": "{day}日 {weekday}",
+ "{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}",
+ "{what} merge added": "已新增{what}合併照片",
+ "{what} merge failed: {e}": "{what}合併失敗:{e}",
+ "{year}": "{year}年",
+ "{} 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": "此照片有 {} 個斑點",
+ "· {dups} already in the library": "· {dups} 張已在照片庫中",
+ "· {n} photo{}": "· {n} 張照片{:.0}",
+ "… and {more} more": "…以及另外 {more} 個",
+ " ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)",
+ "Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}",
+ "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入"
+}
diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json
new file mode 100644
index 000000000..79c952917
--- /dev/null
+++ b/crates/ui-egui/locales/zh-hant.json
@@ -0,0 +1,1248 @@
+{
+ "+ 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": "處理狀態",
+ "Actual size": "實際大小",
+ "Add": "新增",
+ "Add Catchlight": "新增眼神光",
+ "Add Folder to Local": "將資料夾加入本機",
+ "Add Point Color Sample": "新增點色彩樣本",
+ "Add Red Eye Correction": "新增紅眼校正",
+ "Add Remove Spot": "新增移除斑點",
+ "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 to Target Album": "加入目標相簿",
+ "Add/Subtract/Intersect Mask": "新增/減去/交集遮色片",
+ "Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。",
+ "Adding": "正在新增",
+ "Advanced search…": "進階搜尋…",
+ "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 Color Label Set": "套用色彩標籤集",
+ "Apply Filter Preset": "套用篩選預設集",
+ "Apply Metadata Preset": "套用中繼資料預設集",
+ "Apply Point Curve Preset": "套用點曲線預設集",
+ "Apply Preset": "套用預設集",
+ "Apply Settings JSON": "套用 JSON 設定",
+ "Approved": "已核准",
+ "Aqua": "青色",
+ "Aqua Hue": "青色色相",
+ "Aqua Luminance": "青色明度",
+ "Aqua Saturation": "青色飽和度",
+ "ArtCraft Website": "ArtCraft 網站",
+ "ArtCraft — more creative apps": "ArtCraft — 更多創作應用程式",
+ "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 B&W Mix": "自動黑白混色",
+ "Auto Crop": "自動裁切",
+ "Auto Import": "自動匯入",
+ "Auto Import Now": "立即自動匯入",
+ "Auto Import Settings": "自動匯入設定",
+ "Auto Mask": "自動遮色片",
+ "Auto Settings": "自動設定",
+ "Auto Straighten": "自動拉直",
+ "Auto Sync": "自動同步",
+ "Auto-Stack by Capture Time": "依拍攝時間自動堆疊",
+ "Auto-Stack by Capture Time…": "依拍攝時間自動堆疊…",
+ "Auto-Tag Photos from Tracklog": "依軌跡記錄自動標記照片位置",
+ "Auto-Tag from Tracklog": "依軌跡記錄自動加入位置標記",
+ "Auto-Tag from Tracklog…": "依軌跡記錄自動加入位置標記…",
+ "Automatic": "自動",
+ "Automatically Write Changes into XMP": "自動將變更寫入 XMP",
+ "Automatically write changes into XMP sidecars": "自動將變更寫入 XMP 附屬檔案",
+ "Autumn Gold": "秋日金色",
+ "B&W": "黑白",
+ "B&W Mix": "黑白混色",
+ "B&W Mixer": "黑白混色器",
+ "Back": "返回",
+ "Back Up Library…": "備份照片庫…",
+ "Back to Edit": "返回編輯",
+ "Back to Edit (E)": "返回編輯(E)",
+ "Back to Grid": "返回格狀檢視",
+ "Background": "背景",
+ "Balance": "平衡",
+ "Basic": "基本",
+ "Before": "編輯前",
+ "Before/After (Y)": "編輯前/後(Y)",
+ "Before/After Settings": "編輯前/後設定",
+ "Before/After Split": "編輯前/後分割檢視",
+ "Before/After Split Top/Bottom": "編輯前/後上下分割檢視",
+ "Before/After Top/Bottom": "編輯前/後上下檢視",
+ "Begin Interaction": "開始互動",
+ "Bit depth": "位元深度",
+ "Black & White": "黑白",
+ "Blacks": "黑色色階",
+ "Bleach Bypass": "漂白略過",
+ "Bleached": "漂白效果",
+ "Blending": "混合",
+ "Blue": "藍色",
+ "Blue Hour Boost": "藍調時刻增強",
+ "Blue Hue": "藍色色相",
+ "Blue Luminance": "藍色明度",
+ "Blue Primary": "藍色原色",
+ "Blue Saturation": "藍色飽和度",
+ "Blue channel": "藍色通道",
+ "Blue/Yellow Fringe": "藍/黃色邊緣",
+ "Blurry photos below the score are flagged as rejects.": "焦點分數低於門檻的模糊照片會標記為排除。",
+ "Boundary Warp": "邊界變形",
+ "Bright & Airy": "明亮輕盈",
+ "Bring All to Front": "將所有視窗移至最前方",
+ "Browse Folder": "瀏覽資料夾",
+ "Browse Folder…": "瀏覽資料夾…",
+ "Browse Profiles": "瀏覽描述檔",
+ "Browse…": "瀏覽…",
+ "Brush": "筆刷",
+ "Brush Settings": "筆刷設定",
+ "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": "取消",
+ "Cancel Interaction": "取消互動",
+ "Cancel Preview Build": "取消建立預覽",
+ "Candidate": "候選照片",
+ "Caption": "圖說",
+ "Capture Date": "拍攝日期",
+ "Captured": "拍攝時間",
+ "Catalog Statistics": "目錄統計",
+ "Check All": "全部勾選",
+ "Check Output Path": "檢驗輸出路徑",
+ "Choose Another Library…": "選擇其他照片庫…",
+ "Choose Edit Settings to Copy": "選擇要複製的編輯設定",
+ "Choose Edit Settings to Copy…": "選擇要複製的編輯設定…",
+ "Choose Folder…": "選擇資料夾…",
+ "Choose a photo in the grid or filmstrip": "請在格狀檢視或底片顯示窗格中選擇照片",
+ "Choose…": "選擇…",
+ "Cinematic": "電影風格",
+ "City": "城市",
+ "Clarity": "清晰度",
+ "Clean Studio": "純淨棚拍",
+ "Clear": "清除",
+ "Clear Cache": "清除快取",
+ "Clear Filters": "清除篩選條件",
+ "Clear Guides": "清除參考線",
+ "Clear History": "清除歷史紀錄",
+ "Clear Preview Cache": "清除預覽快取",
+ "Clear Quick Collection": "清除快速集合",
+ "Clear Reference": "清除參考照片",
+ "Clear all": "全部清除",
+ "Clear form": "清除表單",
+ "Cleared History": "已清除歷史紀錄",
+ "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": "關閉視窗",
+ "Close all": "全部關閉",
+ "Close file": "關閉檔案",
+ "Cloudy": "陰天",
+ "Collapse All Stacks": "收合所有堆疊",
+ "Color": "色彩",
+ "Color Grading": "色彩分級",
+ "Color Label Names": "色彩標籤名稱",
+ "Color Label Sets": "色彩標籤集",
+ "Color Mixer": "混色器",
+ "Color Noise Reduction": "色彩雜訊抑制",
+ "Color Priority": "色彩優先",
+ "Color Range": "色彩範圍",
+ "Color space": "色彩空間",
+ "Color space: sRGB (AVIF)": "色彩空間:sRGB(AVIF)",
+ "Combine files…": "合併檔案…",
+ "Command Journal": "指令記錄",
+ "Comments panel": "註解面板",
+ "Compare": "比較",
+ "Compare (Shift+C)": "比較(Shift+C)",
+ "Compare Before and After": "比較編輯前與編輯後",
+ "Compare needs at least two photos": "比較需要至少兩張照片",
+ "Component options": "元件選項",
+ "Compression": "壓縮",
+ "Concrete Cool": "冷調混凝土",
+ "Confirm before deleting": "刪除前確認",
+ "Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認",
+ "Constrain Crop": "限制裁切範圍",
+ "Continue Without Saving": "繼續使用但不儲存",
+ "Contrast": "對比",
+ "Convert to DNG": "轉換為 DNG",
+ "Cool Fade": "冷調褪色",
+ "Cool Morning": "清冷晨光",
+ "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 pages": "複製頁面",
+ "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": "著作權資訊網址",
+ "Copyright Status": "著作權狀態",
+ "Country": "國家",
+ "Create Album": "建立相簿",
+ "Create Album…": "建立相簿…",
+ "Create Folder": "建立資料夾",
+ "Create Folder…": "建立資料夾…",
+ "Create New Mask": "建立新遮色片",
+ "Create PDF from clipboard": "從剪貼簿建立 PDF",
+ "Create PDF from file…": "從檔案建立 PDF…",
+ "Create PDF from images…": "從圖片建立 PDF…",
+ "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": "創作者",
+ "Crisp Landscape": "清晰風景",
+ "Crisp Vista": "清晰遠景",
+ "Crop": "裁切",
+ "Crop & Rotate": "裁切與旋轉",
+ "Crop & Rotate (C)": "裁切與旋轉(C)",
+ "Crop Aspect": "裁切長寬比",
+ "Cross Process": "交叉沖洗",
+ "Culling": "挑片",
+ "Curve": "曲線",
+ "Custom": "自訂",
+ "Custom…": "自訂…",
+ "Cut": "剪下",
+ "Cut pages": "剪下頁面",
+ "Cycle Crop Overlay": "切換裁切疊加",
+ "Cycle Crop Overlay Orientation": "切換裁切疊加方向",
+ "Cycle Info Overlay": "切換資訊疊加",
+ "Cycle Mask Overlay Color": "切換遮色片疊加色彩",
+ "Cycle Mask Overlay Mode": "切換遮色片疊加模式",
+ "Cylindrical": "圓柱形",
+ "Dark & Moody": "暗調氛圍",
+ "Darken": "變暗",
+ "Darks": "暗部",
+ "Date Groups": "日期群組",
+ "Day": "日",
+ "Daylight": "日光",
+ "Decrease Brush Feather": "減少筆刷羽化",
+ "Decrease Brush Size": "縮小筆刷尺寸",
+ "Deep Sky": "深邃天空",
+ "Defringe": "去除邊緣色差",
+ "Deghost Amount": "去除鬼影強度",
+ "Dehaze": "去朦朧",
+ "Delete": "刪除",
+ "Delete (⌫)": "刪除(⌫)",
+ "Delete Album": "刪除相簿",
+ "Delete All Masks": "刪除所有遮色片",
+ "Delete Color Label Set": "刪除色彩標籤集",
+ "Delete Export Preset": "刪除匯出預設集",
+ "Delete Filter Preset": "刪除篩選預設集",
+ "Delete Imported Profile": "刪除匯入的描述檔",
+ "Delete Keyword": "刪除關鍵字",
+ "Delete Keyword Set": "刪除關鍵字集",
+ "Delete Mask": "刪除遮色片",
+ "Delete Metadata Preset": "刪除中繼資料預設集",
+ "Delete Permanently": "永久刪除",
+ "Delete Photo": "刪除照片",
+ "Delete Photos": "刪除照片",
+ "Delete Point Color Sample": "刪除點色彩樣本",
+ "Delete Point Curve Preset": "刪除點曲線預設集",
+ "Delete Preset": "刪除預設集",
+ "Delete Red Eye Correction": "刪除紅眼校正",
+ "Delete Spot": "刪除斑點",
+ "Delete Version": "刪除版本",
+ "Delete all": "全部刪除",
+ "Delete all spots": "刪除所有斑點",
+ "Delete mask": "刪除遮色片",
+ "Delete them": "刪除這些照片",
+ "Density": "濃度",
+ "Depth Range": "深度範圍",
+ "Descending": "遞減",
+ "Deselect": "取消選取",
+ "Deselect All": "取消全選",
+ "Desert Heat": "沙漠熱浪",
+ "Desert Warmth": "沙漠暖色",
+ "Detail": "詳細檢視",
+ "Detail (D)": "詳細檢視(D)",
+ "Diagonal": "對角線",
+ "Discard Preview Cache": "捨棄預覽快取",
+ "Display P3": "Display P3",
+ "Display theme": "顯示主題",
+ "Distortion": "變形",
+ "Document properties…": "文件內容…",
+ "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 Color Label Names…": "編輯色彩標籤名稱…",
+ "Edit Copy for External Editor": "編輯外部編輯器副本",
+ "Edit Info": "編輯資訊",
+ "Edit Label Names…": "編輯標籤名稱…",
+ "Edit Mask Component": "編輯遮色片元件",
+ "Edit Sections": "編輯區段",
+ "Edit Smart Album": "編輯智慧型相簿",
+ "Edit Smart Album…": "編輯智慧型相簿…",
+ "Edit Spot": "編輯斑點",
+ "Edit in External Editor": "在外部編輯器中編輯",
+ "Edited": "已編輯",
+ "Effect": "效果",
+ "Effects": "效果",
+ "Enable Lens Corrections": "啟用鏡頭校正",
+ "End Interaction": "結束互動",
+ "English": "English",
+ "Enter Full Screen": "進入全螢幕",
+ "Erase": "擦除",
+ "Every EXIF, GPS and XMP field in the file": "檔案中的所有 EXIF、GPS 與 XMP 欄位",
+ "Existing previews": "現有預覽",
+ "Exit": "結束",
+ "Expand All Stacks": "展開所有堆疊",
+ "Expand/Collapse Stack": "展開/收合堆疊",
+ "Export": "匯出",
+ "Export Album with Preset": "使用預設集匯出相簿",
+ "Export Album…": "匯出相簿…",
+ "Export As…": "匯出為…",
+ "Export Group…": "匯出群組…",
+ "Export Now": "立即匯出",
+ "Export Point Curve Presets": "匯出點曲線預設集",
+ "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": "外部編輯器",
+ "Faded Matte": "褪色霧面",
+ "Faded Slide": "褪色正片",
+ "Faded Urban": "褪色城市",
+ "Favorite Preset": "將預設集加入喜好項目",
+ "Favorite Profile": "將描述檔加入喜好項目",
+ "Favorites": "喜好項目",
+ "Feather": "羽化",
+ "File": "檔案",
+ "File & date": "檔案與日期",
+ "File Name": "檔案名稱",
+ "File Path": "檔案路徑",
+ "File Size": "檔案大小",
+ "File name": "檔案名稱",
+ "Fill Edges": "填補邊緣",
+ "Film": "底片",
+ "Filmstrip": "底片顯示窗格",
+ "Filmstrip (/)": "底片列(/)",
+ "Filter": "篩選",
+ "Filter Bar": "篩選列",
+ "Filter Presets": "篩選預設集",
+ "Filter bar": "篩選列",
+ "Filter fields": "篩選欄位",
+ "Find Dust Spots": "尋找灰塵斑點",
+ "Find Missing Photos": "尋找遺失的照片",
+ "Find Missing Photos…": "尋找遺失的照片…",
+ "Find Similar Photos": "尋找相似照片",
+ "Find tools and commands…": "尋找工具與指令…",
+ "Find…": "尋找…",
+ "Fit": "符合視窗",
+ "Fit (F)": "符合視窗(F)",
+ "Fit on Screen": "符合螢幕",
+ "Fit to width": "符合寬度",
+ "Fit visible": "符合可見範圍",
+ "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} 使用拍攝時間;沒有拍攝時間的照片使用今天的日期。",
+ "Food": "美食",
+ "Forget Unchanged Local Photos": "忘記未變更的本機照片",
+ "Forget unchanged photos": "忘記未變更的照片",
+ "Form fields panel": "表單欄位面板",
+ "Format": "格式",
+ "Forward": "前進",
+ "Free": "自由",
+ "Fresh & Bright": "清新明亮",
+ "Friday": "星期五",
+ "Full": "完整",
+ "Full Screen Preview": "全螢幕預覽",
+ "Full screen mode": "全螢幕模式",
+ "Full size": "完整尺寸",
+ "GPS Track Log": "GPS 軌跡記錄",
+ "GPU Rendering": "GPU 算繪",
+ "General": "一般",
+ "Geometry": "幾何",
+ "Get Develop Settings": "取得顯像設定",
+ "Global Hue": "整體色相",
+ "Global Luminance": "整體明度",
+ "Global Saturation": "整體飽和度",
+ "Glossy": "亮面",
+ "Go to History Step": "前往歷史步驟",
+ "Golden": "黃金比例",
+ "Golden Glow": "金色光暈",
+ "Golden Hour": "黃金時刻",
+ "Grain": "顆粒",
+ "Graphic": "圖像",
+ "Green": "綠色",
+ "Green Amount": "綠色強度",
+ "Green Hue": "綠色色相",
+ "Green Hue High": "綠色色相上限",
+ "Green Hue Low": "綠色色相下限",
+ "Green Luminance": "綠色明度",
+ "Green Primary": "綠色原色",
+ "Green Saturation": "綠色飽和度",
+ "Green channel": "綠色通道",
+ "Grid": "格狀檢視",
+ "Grid Info": "格狀檢視資訊",
+ "Gritty Street": "粗礪街頭",
+ "Group": "群組",
+ "Group by Date: Automatic": "依日期分組:自動",
+ "Group by Day": "依日分組",
+ "Group by Month": "依月分組",
+ "Group by Year": "依年分組",
+ "Group by date": "依日期分組",
+ "Group into Stack": "分組為堆疊",
+ "Guided": "引導式",
+ "Guided Upright": "引導式透視校正",
+ "Guided Upright Guides": "引導式透視校正參考線",
+ "HDR Merge": "HDR 合併",
+ "HDR Panorama Merge": "HDR 全景合併",
+ "HDR Panorama with Last Settings": "使用上次設定合併 HDR 全景",
+ "HDR Panorama…": "HDR 全景…",
+ "HDR with Last Settings": "使用上次設定合併 HDR",
+ "HDR…": "HDR…",
+ "Heal": "修復",
+ "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點",
+ "Height": "高度",
+ "Height (px)": "高度(像素)",
+ "Help": "說明",
+ "Hide LightCraft": "隱藏 LightCraft",
+ "Hide Mask": "隱藏遮色片",
+ "Hide Others": "隱藏其他應用程式",
+ "Hides this shortcut only; the folder and its photos stay as they are": "只隱藏此捷徑,資料夾及照片維持原狀",
+ "High": "高",
+ "High Contrast B&W": "高對比黑白",
+ "Highlight": "亮部",
+ "Highlights": "亮部",
+ "Highlights Hue": "亮部色相",
+ "Highlights Luminance": "亮部明度",
+ "Highlights Saturation": "亮部飽和度",
+ "Highlights split": "亮部分界",
+ "Histogram": "色階分佈圖",
+ "History": "歷史紀錄",
+ "History & Activity (Y)": "歷史紀錄與處理狀態(Y)",
+ "Horizontal": "水平",
+ "Horizontal text": "水平文字",
+ "Hue": "色相",
+ "Hue Range": "色相範圍",
+ "If file exists": "檔案已存在時",
+ "Image": "圖片",
+ "Import": "匯入",
+ "Import Date": "匯入日期",
+ "Import Photos": "匯入照片",
+ "Import Photos…": "匯入照片…",
+ "Import Point Curve Presets": "匯入點曲線預設集",
+ "Import Point Curve Presets…": "匯入點曲線預設集…",
+ "Import Presets": "匯入預設集",
+ "Import Presets…": "匯入預設集…",
+ "Import Profiles": "匯入描述檔",
+ "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": "包含子資料夾",
+ "Increase Brush Feather": "增加筆刷羽化",
+ "Increase Brush Size": "放大筆刷尺寸",
+ "Info": "資訊",
+ "Info (I)": "資訊(I)",
+ "Info overlay": "資訊疊加",
+ "Inspect Photo": "檢驗照片",
+ "Instant ’70s": "七〇年代拍立得",
+ "Interface": "介面",
+ "Interface language": "介面語言",
+ "Intersect": "交集",
+ "Intersect with": "與下列項目取交集",
+ "Into one folder": "放入同一資料夾",
+ "Invert": "反轉",
+ "Invert Mask": "反轉遮色片",
+ "JPEG (Large)": "JPEG(大尺寸)",
+ "JPEG (Small)": "JPEG(小尺寸)",
+ "Join the ArtCraft Discord": "加入 ArtCraft Discord",
+ "Join the ArtCraft Discord…": "加入 ArtCraft Discord…",
+ "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 (K)": "關鍵字(K)",
+ "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": "橫向色差",
+ "Layer": "圖層",
+ "Layers": "圖層",
+ "Leave them": "保留這些照片",
+ "Lens": "鏡頭",
+ "Lens Corrections": "鏡頭校正",
+ "Level": "水平校正",
+ "Library": "照片庫",
+ "Library Info": "照片庫資訊",
+ "Library Originals": "照片庫原始檔案",
+ "Library Preferences": "照片庫偏好設定",
+ "Library State": "照片庫狀態",
+ "Light": "光線",
+ "LightCraft Default": "LightCraft 預設值",
+ "LightCraft Help": "LightCraft 輔助說明",
+ "LightCraft Preset": "LightCraft 預設集",
+ "LightCraft Presets": "LightCraft 預設集",
+ "LightCraft Website": "LightCraft 網站",
+ "LightCraft couldn't find where to keep your library.": "LightCraft 找不到儲存照片庫的位置。",
+ "LightCraft couldn't open the library at": "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 Albums": "列出相簿",
+ "List Develop Controls": "列出顯像控制項",
+ "List History": "列出歷史紀錄",
+ "List Presets": "列出預設集",
+ "List Profiles": "列出描述檔",
+ "List this folder in Local from now on": "將此資料夾永久列在本機",
+ "Local": "本機",
+ "Local folders": "本機資料夾",
+ "Local library — no cloud account needed": "本機照片庫,無需雲端帳號",
+ "Locate Missing File…": "尋找遺失的檔案…",
+ "Locate Photo": "尋找照片",
+ "Location": "位置",
+ "Long Edge": "長邊",
+ "Looking for photos…": "正在尋找照片…",
+ "Lossless": "無損",
+ "Low": "低",
+ "Luminance": "明度",
+ "Luminance Range": "明度範圍",
+ "Lush Greens": "濃郁綠意",
+ "Magenta": "洋紅色",
+ "Magenta Hue": "洋紅色色相",
+ "Magenta Luminance": "洋紅色明度",
+ "Magenta Saturation": "洋紅色飽和度",
+ "Make Candidate the Select": "將候選照片設為選定照片",
+ "Manual": "手動",
+ "Marquee zoom": "選取範圍縮放",
+ "Masking": "遮色片",
+ "Masking (M)": "遮色片(M)",
+ "Masks": "遮色片",
+ "Match": "符合",
+ "Match Total Exposures": "符合總曝光量",
+ "Matte": "霧面",
+ "Matte Soft": "柔和霧面",
+ "Med": "中",
+ "Medium Contrast": "中等對比",
+ "Megapixels": "百萬像素",
+ "Memory Budget": "記憶體預算",
+ "Memory Usage": "記憶體使用量",
+ "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": "遺失的照片",
+ "Misty Morning": "薄霧清晨",
+ "Mode": "模式",
+ "Modified Date": "修改日期",
+ "Monday": "星期一",
+ "Mono Red Filter": "黑白紅色濾鏡",
+ "Mono Rich": "濃郁黑白",
+ "Mono Soft": "柔和黑白",
+ "Monochrome": "單色",
+ "Month": "月",
+ "Moody": "氛圍風格",
+ "Moody Portrait": "氛圍人像",
+ "More": "更多",
+ "More preset options": "更多預設集選項",
+ "Move": "移動",
+ "Move Album": "移動相簿",
+ "Move Down in Stack": "在堆疊中下移",
+ "Move Folder": "移動資料夾",
+ "Move Folder To…": "將資料夾移至…",
+ "Move Mask": "移動遮色片",
+ "Move Preset to Group": "將預設集移至群組",
+ "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 附屬檔案只會在複本通過驗證後才從來源移除。重複及失敗的檔案會留在原處。",
+ "Muted": "低彩度",
+ "Muted Film": "低彩度底片",
+ "My Photos": "我的照片",
+ "My Photos Panel": "我的照片面板",
+ "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": "需要調整",
+ "Neon Dusk": "霓虹暮色",
+ "Neon Night": "霓虹之夜",
+ "Neutral": "中性",
+ "Never": "永不",
+ "New": "新增",
+ "New Album": "新增相簿",
+ "New Album…": "新增相簿…",
+ "New Folder…": "新增資料夾…",
+ "New Group…": "新增群組…",
+ "New Smart Album": "新增智慧型相簿",
+ "New Smart Album from Filter…": "依篩選條件新增智慧型相簿…",
+ "New Smart Album…": "新增智慧型相簿…",
+ "New album": "新增相簿",
+ "New album…": "新增相簿…",
+ "New blank PDF": "新增空白 PDF",
+ "New name": "新名稱",
+ "New…": "新增…",
+ "Next Photo": "下一張照片",
+ "Night Blue": "夜色藍調",
+ "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": "沒有可總覽的照片",
+ "Nudge Develop Value": "微調顯像數值",
+ "OK": "確定",
+ "Object": "物件",
+ "Off": "關閉",
+ "Offset X": "X 位移",
+ "Offset Y": "Y 位移",
+ "Opacity": "不透明度",
+ "Opacity (%)": "不透明度(%)",
+ "Open Library": "開啟照片庫",
+ "Open Library…": "開啟照片庫…",
+ "Open in": "開啟於",
+ "Open in Detail": "在詳細檢視中開啟",
+ "Open the place in OpenStreetMap": "在 OpenStreetMap 中開啟此位置",
+ "Open…": "開啟…",
+ "Optics": "光學",
+ "Optimize Library": "最佳化照片庫",
+ "Optional name": "名稱(選填)",
+ "Options": "選項",
+ "Orange": "橙色",
+ "Orange Hue": "橙色色相",
+ "Orange Luminance": "橙色明度",
+ "Orange Saturation": "橙色飽和度",
+ "Orientation": "方向",
+ "Original": "原始",
+ "Original + Settings": "原始檔與設定",
+ "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": "覆寫",
+ "Pages": "頁面",
+ "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 with Last Settings": "使用上次設定合併全景",
+ "Panorama…": "全景…",
+ "Parametric curve": "參數曲線",
+ "Paste": "貼上",
+ "Paste Edit Settings": "貼上編輯設定",
+ "Paste Metadata": "貼上中繼資料",
+ "Paste Selected Settings": "貼上選取的設定",
+ "Paste Selected Settings…": "貼上選取的設定…",
+ "Paste Settings from Previous": "貼上前一張照片的設定",
+ "Paste pages": "貼上頁面",
+ "Pastel Haze": "粉彩薄霧",
+ "Percent": "百分比",
+ "Percentage": "百分比",
+ "Performance": "效能",
+ "Perspective": "透視",
+ "Pet Eye": "寵物眼睛",
+ "Pet Eye Catchlight": "寵物眼神光",
+ "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": "位置",
+ "Preferences": "偏好設定",
+ "Preferences…": "偏好設定…",
+ "Preset": "預設集",
+ "Preset name": "預設集名稱",
+ "Presets": "預設集",
+ "Presets & Profiles": "預設集與描述檔",
+ "Presets (Shift+P)": "預設集(Shift+P)",
+ "Presets ▾": "預設集",
+ "Preview": "預覽",
+ "Preview Build Progress": "預覽建立進度",
+ "Preview only": "僅供預覽",
+ "Preview size": "預覽尺寸",
+ "Previews": "預覽",
+ "Previous Photo": "上一張照片",
+ "Print": "列印",
+ "Print…": "列印…",
+ "Profile": "描述檔",
+ "Profile Browser": "描述檔瀏覽器",
+ "Profile Distortion": "描述檔變形校正",
+ "Profile Menu": "描述檔選單",
+ "Profile Vignetting": "描述檔暗角校正",
+ "Profile:": "描述檔:",
+ "Profiles": "描述檔",
+ "Projection": "投影",
+ "Properties": "屬性",
+ "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": "品質",
+ "Query Photos": "搜尋照片",
+ "Quick Collection": "快速集合",
+ "Quick Develop": "快速顯像",
+ "Quiet Green": "靜謐綠色",
+ "Quit": "結束",
+ "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": "從檔案讀取中繼資料",
+ "Read mode": "閱讀模式",
+ "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": "參照檔案目前的位置",
+ "Refine Mask Edges": "調整遮色片邊緣",
+ "Refresh Source": "重新整理來源",
+ "Refresh Source (/)": "重新整理來源(/)",
+ "Reject": "排除",
+ "Reject below focus": "排除低於此焦點分數的照片",
+ "Rejected": "已排除",
+ "Rejects": "排除的照片",
+ "Reload from Disk": "從磁碟重新載入",
+ "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 Folder…": "重新命名資料夾…",
+ "Rename Keyword": "重新命名關鍵字",
+ "Rename Keyword…": "重新命名關鍵字…",
+ "Rename Mask": "重新命名遮色片",
+ "Rename Photos": "重新命名照片",
+ "Rename Photos…": "重新命名照片…",
+ "Rename Preset": "重新命名預設集",
+ "Rename Preview": "重新命名預覽",
+ "Rename Template Tags": "重新命名範本標籤",
+ "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": "還原",
+ "Restore Library from Backup…": "從備份還原照片庫…",
+ "Restore Version": "還原版本",
+ "Retouch": "修飾",
+ "Revert": "回復至上次儲存",
+ "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 Crop Aspect": "旋轉裁切長寬比",
+ "Rotate Left": "向左旋轉",
+ "Rotate Right": "向右旋轉",
+ "Roughness": "粗糙度",
+ "Roundness": "圓度",
+ "Same as raw default": "與 RAW 預設值相同",
+ "Sample Mask Color": "取樣遮色片色彩",
+ "Sample a colour on the photo": "在照片上取樣色彩",
+ "Saturation": "飽和度",
+ "Saturation Range": "飽和度範圍",
+ "Saturday": "星期六",
+ "Save": "儲存",
+ "Save As…": "另存新檔…",
+ "Save Color Label Set": "儲存色彩標籤集",
+ "Save Current Filter as Preset…": "將目前篩選條件儲存為預設集…",
+ "Save Current Keywords as Set…": "將目前關鍵字儲存為關鍵字集…",
+ "Save Export Preset": "儲存匯出預設集",
+ "Save Filter Preset": "儲存篩選預設集",
+ "Save Keyword Set": "儲存關鍵字集",
+ "Save Metadata Preset": "儲存中繼資料預設集",
+ "Save Metadata Preset…": "儲存中繼資料預設集…",
+ "Save Metadata to File": "將中繼資料儲存至檔案",
+ "Save Point Curve Preset": "儲存點曲線預設集",
+ "Save Point Curve…": "儲存點曲線…",
+ "Save as Preset": "儲存為預設集",
+ "Save as Smart Album…": "儲存為智慧型相簿…",
+ "Save as set": "儲存為關鍵字集",
+ "Save as…": "另存新檔…",
+ "Save preset": "儲存預設集",
+ "Scale": "縮放",
+ "Scanning": "正在掃描",
+ "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "分數會保留在照片上:可依焦點與相似照片中的最佳照片篩選,或建立智慧型相簿。",
+ "Screen": "螢幕",
+ "Search Photos": "搜尋照片",
+ "Search…": "搜尋…",
+ "Seasons": "四季",
+ "Second Window": "第二視窗",
+ "Select": "選定照片",
+ "Select All": "全選",
+ "Select Luminance Range": "選取明度範圍",
+ "Select Mask": "選取遮色片",
+ "Select Photos": "選取照片",
+ "Select Spot": "選取斑點",
+ "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": "已選取",
+ "Selenium Tone": "硒調色",
+ "Send Feedback…": "傳送意見回饋…",
+ "Sepia Tone": "深褐色調",
+ "Services": "服務",
+ "Set Before": "設定編輯前",
+ "Set Color Label": "設定色彩標籤",
+ "Set Crop": "設定裁切",
+ "Set Develop Values": "設定顯像參數",
+ "Set Flag": "設定旗標",
+ "Set Mask Adjustments": "設定遮色片調整",
+ "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 Mask": "顯示/隱藏遮色片",
+ "Show/hide My Photos panel": "顯示/隱藏我的照片面板",
+ "Shutter speed": "快門速度",
+ "Side panels": "側邊面板",
+ "Sidecar names": "附屬檔案名稱",
+ "Size": "尺寸",
+ "Size (% of short edge)": "尺寸(短邊百分比)",
+ "Size limit": "大小上限",
+ "Skip": "略過",
+ "Sky": "天空",
+ "Slideshow": "幻燈片播放",
+ "Smart Album": "智慧型相簿",
+ "Smart Album Rule Fields": "智慧型相簿規則欄位",
+ "Smart Preview Status": "智慧型預覽狀態",
+ "Smart Previews Location": "智慧型預覽位置",
+ "Smart previews": "智慧型預覽",
+ "Smoothness": "平滑度",
+ "Soft B&W": "柔和黑白",
+ "Soft Pastel": "柔和粉彩",
+ "Soft Proofing": "軟式校樣",
+ "Soft Skin": "柔和膚質",
+ "Sort": "排序",
+ "Sort by": "排序依據",
+ "Source": "來源",
+ "Source code on GitHub": "GitHub 上的原始碼",
+ "Spherical": "球面",
+ "Spiral": "螺旋",
+ "Split Stack": "分割堆疊",
+ "Spring Fresh": "清新春日",
+ "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": "拉直工具",
+ "Strong Contrast": "強烈對比",
+ "Style": "樣式",
+ "Subfolder": "子資料夾",
+ "Subfolder name": "子資料夾名稱",
+ "Subject": "主體",
+ "Subtract": "減去",
+ "Suggestions": "建議",
+ "Summer Haze": "夏日薄霧",
+ "Sunday": "星期日",
+ "Survey": "總覽",
+ "Survey (N)": "總覽(N)",
+ "Swap": "交換",
+ "Swap Aspect (X)": "交換長寬比(X)",
+ "Swap Before and After Settings": "交換編輯前與編輯後設定",
+ "Swap Compare Photos": "交換比較照片",
+ "Switch light / dark theme": "切換淺色/深色主題",
+ "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…",
+ "Take a snapshot": "擷取快照",
+ "Targeted Adjustment": "目標調整",
+ "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": "目標調整:在照片上向上/下拖曳,調整該位置的曲線",
+ "Teal & Amber": "青綠與琥珀",
+ "Teal & Orange": "青綠與橙色",
+ "Temp": "色溫",
+ "Temperature": "色溫",
+ "Template": "範本",
+ "Temporary session — your library isn't open, and nothing you do here is saved.": "暫存工作階段:照片庫未開啟,此處的操作不會儲存。",
+ "Text": "文字",
+ "Text direction": "文字方向",
+ "Texture": "紋理",
+ "The original files, unchanged, each with an XMP sidecar holding its edits.": "保留未變更的原始檔案,每個檔案附有記錄編輯設定的 XMP 附屬檔案。",
+ "The photo has these settings": "此照片具有這些設定",
+ "Theme": "主題",
+ "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": "縮圖快取",
+ "Thursday": "星期四",
+ "Tint": "色調",
+ "Title": "標題",
+ "Toggle Keyword from Set": "切換關鍵字集中的關鍵字",
+ "Toggle Section": "切換區段",
+ "Toggle Zoom": "切換縮放",
+ "Tone Curve": "色調曲線",
+ "Tools": "工具",
+ "Top Level": "最上層",
+ "Transfer": "傳輸",
+ "Treatment": "處理方式",
+ "Triangle": "三角形",
+ "Try Again": "再試一次",
+ "Tuesday": "星期二",
+ "Tungsten": "鎢絲燈",
+ "Turn Off": "關閉",
+ "Turn on Include subfolders, or pick another folder under Local": "啟用「包含子資料夾」,或在本機中選擇其他資料夾",
+ "Type": "類型",
+ "Uncheck All": "全部取消勾選",
+ "Undo": "復原",
+ "Unedited": "未編輯",
+ "Unflag": "移除旗標",
+ "Unflagged": "無旗標",
+ "Ungroup Stack": "取消堆疊",
+ "Unknown Date": "未知日期",
+ "Unrated": "未評分",
+ "Unreadable": "無法讀取",
+ "Update": "更新",
+ "Update Mask Shape": "更新遮色片形狀",
+ "Update Preset with Current Settings": "以目前設定更新預設集",
+ "Update Rules from Current Filter": "依目前篩選條件更新規則",
+ "Update Version": "更新版本",
+ "Update Version with Current Settings": "以目前設定更新版本",
+ "Update with Current Settings": "以目前設定更新",
+ "Upright": "透視校正",
+ "Urban": "城市",
+ "Use Keyword Set": "使用關鍵字集",
+ "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": "垂直",
+ "Vertical text": "垂直文字",
+ "Vibrance": "自然飽和度",
+ "Videos": "影片",
+ "View": "檢視",
+ "Vignette": "暗角",
+ "Vignetting": "暗角",
+ "Vintage": "復古",
+ "Virtual Copy": "虛擬副本",
+ "Visible": "可見照片",
+ "Visualize Spots": "顯示斑點",
+ "Visualize Spots (A)": "顯示斑點(A)",
+ "Visualize range": "顯示範圍",
+ "Vivid": "鮮豔",
+ "Vivid Pop": "鮮明色彩",
+ "Warm Film": "暖調底片",
+ "Warm Glow": "暖光",
+ "Warm Print": "暖調沖印",
+ "Warm Table": "溫暖餐桌",
+ "Watched folder": "監看的資料夾",
+ "Watermark": "浮水印",
+ "Wednesday": "星期三",
+ "What's New": "新增功能",
+ "When rated or hovered": "評分或游標停留時",
+ "White Balance": "白平衡",
+ "White Balance Selector": "白平衡選取器",
+ "White Balance Selector (W)": "白平衡選取器(W)",
+ "Whites": "白色色階",
+ "Width": "寬度",
+ "Width (% of photo)": "寬度(照片百分比)",
+ "Width (px)": "寬度(像素)",
+ "Width × Height": "寬度 × 高度",
+ "Window": "視窗",
+ "Winter Blue": "冬日藍調",
+ "XMP Sidecar Preferences": "XMP 附屬檔案偏好設定",
+ "XMP sidecars": "XMP 附屬檔案",
+ "Year": "年",
+ "Yellow": "黃色",
+ "Yellow Hue": "黃色色相",
+ "Yellow Luminance": "黃色明度",
+ "Yellow Saturation": "黃色飽和度",
+ "Yes": "是",
+ "Your library couldn't be opened": "無法開啟你的照片庫",
+ "Zoom": "縮放",
+ "Zoom 100%": "縮放至 100%",
+ "Zoom In": "放大",
+ "Zoom Out": "縮小",
+ "Zoom to Fit": "縮放至符合視窗",
+ "Zoom to page level": "符合頁面大小",
+ "and": "且",
+ "comma, separated": "以逗號分隔",
+ "memory only": "僅記憶體",
+ "none": "無",
+ "not available here": "此處無法使用",
+ "of the following rules:": "下列規則:",
+ "this photo": "這張照片",
+ "日本語": "日本語",
+ "繁體中文(台灣)": "繁體中文(台灣)",
+ "In Recently Deleted": "在「最近刪除」中"
+}
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index d38980654..b592557a7 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -267,6 +267,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
u.dialog = app.ui.dialog.clone();
u.status = app.ui.status.clone();
app.ui = u;
+ crate::i18n::set_language(app.ui.language);
ctx.request_repaint();
ok(serde_json::to_value(&app.ui).unwrap_or_default())
}
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index c208ecb6e..3fc906449 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -118,6 +118,7 @@ macro_rules! language_table {
language_table! {
En, "en";
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
+ ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
}
@@ -183,6 +184,93 @@ fn catalog_verbatim(language: Locale) -> BTreeMap {
// `locales/*-formats.json`.
include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
+/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays
+/// exactly as its owner wrote it.
+pub fn builtin_label(source: &str, builtin: bool) -> &str {
+ if builtin { tr(source) } else { source }
+}
+
+/// An Activity (history) step for display. The step's stored label stays English; generated steps
+/// are translated around the names they carry, and a user preset's name is kept verbatim.
+pub fn history_label(source: &str, presets: &[lightcraft_develop::Preset]) -> String {
+ if let Some(name) = source.strip_prefix("Preset: ") {
+ // A user preset may share a stock preset's name: then the step could be either, so it
+ // keeps the name as written.
+ let builtin = presets.iter().any(|p| p.builtin && p.name == name) && !presets.iter().any(|p| !p.builtin && p.name == name);
+ return tr_format!("Preset: {name}", name = builtin_label(name, builtin));
+ }
+ if let Some(name) = source.strip_prefix("Reset ") {
+ return tr_format!("Reset {name}", name = tr(name));
+ }
+ if let Some(name) = source.strip_prefix("Quick Develop: ") {
+ return tr_format!("Quick Develop: {name}", name = tr(name));
+ }
+ tr(source).to_string()
+}
+
+/// A library source's heading (All Photos, Recently Deleted…) in the UI language; an album's
+/// name is the user's and stays verbatim.
+pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcraft_catalog::Catalog) -> String {
+ let label = source.label(catalog);
+ if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() }
+}
+
+/// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full
+/// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the
+/// catalog's own wording; every other language formats it with its `*-formats.json` date
+/// patterns and weekday names. The grouping keys themselves never change.
+pub fn date_group_label(key: &str, short: bool) -> String {
+ let label = lightcraft_catalog::dates::group_label(key);
+ if language() == Locale::En {
+ return if short {
+ match key.len() {
+ // "September 2026" → "September"
+ 7 => label.split(' ').next().unwrap_or(&label).to_string(),
+ // "Sunday, 20 September 2026" → "Sunday, 20"
+ 10 => label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(),
+ _ => label,
+ }
+ } else {
+ label
+ };
+ }
+ let Some(year) = key.get(..4).and_then(|y| y.parse::().ok()) else { return tr(&label).to_string() };
+ if key.len() == 4 {
+ return tr_format!("{year}", year = year);
+ }
+ let Some(month) = key.get(5..7).and_then(|m| m.parse::().ok()).filter(|m| (1..=12).contains(m)) else {
+ return tr(&label).to_string();
+ };
+ if key.len() == 7 {
+ return if short { tr_format!("{month}", month = month) } else { tr_format!("{month} {year}", month = month, year = year) };
+ }
+ if key.len() == 10
+ && let Some(weekday) = lightcraft_catalog::dates::weekday(key)
+ && let Some(day) = key.get(8..10).and_then(|d| d.parse::().ok())
+ {
+ let weekday = tr(weekday);
+ return if short {
+ tr_format!("{weekday}, {day}", weekday = weekday, day = day)
+ } else {
+ tr_format!("{weekday}, {day} {month} {year}", weekday = weekday, day = day, month = month, year = year)
+ };
+ }
+ tr(&label).to_string()
+}
+
+/// A capture time for display in the UI language (English: "March 30, 2022 at 10:11:11 PM");
+/// the metadata itself is never rewritten, and a value that doesn't parse is shown as it is.
+pub fn display_time(iso: &str) -> String {
+ if language() == Locale::En || lightcraft_catalog::dates::normalize_iso(iso).is_none() {
+ return lightcraft_catalog::dates::display_time(iso);
+ }
+ let date = date_group_label(iso.get(..10).unwrap_or(iso), false);
+ match iso.get(11..).filter(|time| !time.is_empty()) {
+ Some(time) => format!("{date} {time}"),
+ None => date,
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -205,10 +293,11 @@ mod tests {
}
}
- /// The catalogs of the translations agree: same keys, same format placeholders. A source string
- /// added to one language but forgotten in another fails here.
+ /// Where two catalogs translate the same text they agree on its placeholders. Catalogs may cover
+ /// different sets of messages (a message a language lacks shows in English): the gaps are
+ /// reported, not failed, so a translation can grow at its own pace.
#[test]
- fn catalogs_agree_on_keys_and_placeholders() {
+ fn catalogs_agree_on_placeholders_and_report_gaps() {
let fields = |text: &str| {
let mut out: Vec = Vec::new();
let mut rest = text;
@@ -221,18 +310,15 @@ mod tests {
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:?}");
+ let all: std::collections::BTreeSet<&String> = translated.iter().flat_map(|language| language.catalog().keys()).collect();
+ for language in &translated {
+ let catalog = language.catalog();
+ for (key, value) in catalog {
+ assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}");
}
- for key in b.keys() {
- assert!(a.contains_key(key), "{language:?} translates {key:?}, which {first:?} does not");
+ let missing: Vec<&&String> = all.iter().filter(|key| !catalog.contains_key(key.as_str())).collect();
+ if !missing.is_empty() {
+ eprintln!("{} lacks {} message(s) another language translates (shown in English): {missing:?}", language.code(), missing.len());
}
}
}
@@ -245,6 +331,12 @@ mod tests {
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-hant"), Some(Locale::ZhHant));
+ assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant));
+ assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant));
+ assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant));
+ assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant));
+ assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant));
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);
@@ -313,7 +405,7 @@ mod tests {
// 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();
+ lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(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());
@@ -360,8 +452,12 @@ 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.traditionalChinese", Locale::ZhHant),
+ ("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 {
@@ -479,9 +575,9 @@ mod tests {
/// family, Monospace included.
#[test]
fn craft_fonts_render_cjk_in_the_ui() {
- let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字")];
+ let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")];
for (language, sample) in samples {
- if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.covers(language.script())) {
+ if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) {
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
continue;
}
@@ -531,4 +627,104 @@ mod tests {
}
set_language(Locale::En);
}
+
+ /// Traditional Chinese through the menu command and the control channel, persisted, with
+ /// localised headings and dates; ids and user text are untouched.
+ #[test]
+ fn traditional_chinese_switches_through_menu_and_control_and_persists() {
+ use crate::control::{ControlRequest, Outcome};
+ let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
+ app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap();
+ assert_eq!(app.ui.language, Locale::ZhHant);
+ // Applied at once, not on the next frame.
+ assert_eq!(tr("File"), "檔案");
+ assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片");
+ assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日");
+ assert_eq!(date_group_label("2026-09-20", true), "20日 星期日");
+ assert_eq!(date_group_label("2026-09", false), "2026年9月");
+ assert_eq!(date_group_label("2026-09", true), "9月");
+ assert_eq!(date_group_label("2026", false), "2026年");
+ assert_eq!(date_group_label("", false), "未知日期");
+ assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05");
+ assert_eq!(display_time("a-user-value"), "a-user-value");
+ assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true));
+ assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false));
+ assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
+ for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] {
+ assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color");
+ }
+ assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩");
+ assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片");
+ assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片");
+ let saved = serde_json::to_string(&app.ui).unwrap();
+ assert!(saved.contains(r#""language":"zh-hant""#), "{saved}");
+ assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::ZhHant);
+ let ctx = egui::Context::default();
+ app.ui.language = Locale::En;
+ for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] {
+ let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code}));
+ let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") };
+ assert_eq!(reply["ok"], true, "{code}");
+ assert_eq!(app.ui.language, Locale::ZhHant, "{code}");
+ assert_eq!(language(), Locale::ZhHant, "{code} applies at once");
+ }
+ let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"}));
+ let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") };
+ assert_eq!(reply["ok"], false);
+ assert_eq!(app.ui.language, Locale::ZhHant);
+ let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>();
+ let chinese_ids = ids(&app);
+ app.run("app.language.english", serde_json::json!({})).unwrap();
+ assert_eq!(tr("File"), "File");
+ assert_eq!(chinese_ids, ids(&app));
+ }
+
+ /// Dates follow every language's own patterns; English keeps the catalog's wording.
+ #[test]
+ fn date_headings_in_every_language() {
+ set_language(Locale::En);
+ assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026");
+ assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20");
+ assert_eq!(date_group_label("2026-09", true), "September");
+ assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05"));
+ set_language(Locale::Ja);
+ assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日(日曜日)");
+ assert_eq!(date_group_label("2026-09", true), "9月");
+ set_language(Locale::ZhHans);
+ assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日");
+ for language in Locale::ALL {
+ set_language(*language);
+ // A key that is not a date never panics and never shows a raw pattern.
+ for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] {
+ assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}");
+ assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}");
+ }
+ }
+ set_language(Locale::En);
+ }
+
+ /// Stock presets and panel headers are translated; a user preset keeps its name, also when it
+ /// shares a stock preset's name.
+ #[test]
+ fn traditional_chinese_presets_and_panel_headers_are_painted() {
+ 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.presets = true;
+ let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone();
+ app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin });
+ for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] {
+ app.ui.right = panel;
+ let text = painted_text(&ctx, &mut app, Locale::ZhHant);
+ assert!(text.contains(title), "{text}");
+ assert!(!text.contains("History") && !text.contains("Versions"), "{text}");
+ assert!(text.contains("暖光"), "stock preset: {text}");
+ assert!(text.contains("Warm Glow"), "user preset: {text}");
+ assert!(text.contains("色彩"), "stock group: {text}");
+ }
+ assert_eq!(history_label("Exposure", &app.session.presets), "曝光");
+ assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片");
+ assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow");
+ set_language(Locale::En);
+ }
}
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 9f63e1088..1c57d6c85 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -15,6 +15,7 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static
pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"),
("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"),
+ ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
];
@@ -30,6 +31,7 @@ pub fn language_from_command(id: &str) -> Option {
match id {
"app.language.english" => Some(crate::i18n::Locale::En),
"app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans),
+ "app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant),
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
_ => None,
}
@@ -231,6 +233,9 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> {
if let Some(language) = language_from_command(id) {
app.ui.language = language;
+ // Immediately, not on the next frame: the reply and anything else run this frame
+ // (menus rebuilt from it, toasts) are already in the new language.
+ crate::i18n::set_language(app.ui.language);
return Some(Ok(json!(app.ui.language)));
}
let ctx = egui::Context::default();
diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs
index 1ea18d0ed..a9ecdcfe4 100644
--- a/crates/ui-egui/src/panels/dialogs.rs
+++ b/crates/ui-egui/src/panels/dialogs.rs
@@ -466,7 +466,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.separator();
}
builtin = b;
- if ui.selectable_label(false, &p.name).clicked() {
+ if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() {
chosen = Some(p.name);
}
}
@@ -701,7 +701,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Import { opts } => crate::import::body(app, ui, opts),
Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab),
Dialog::ConfirmDelete { count } => {
- let what = if *count == 1 { "this photo".to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
+ let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
}
@@ -710,7 +710,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.add_space(10.0);
- let discord = egui::Button::new(egui::RichText::new("Join the ArtCraft Discord").font(t.semibold(15.0)).color(egui::Color32::WHITE))
+ let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)
.min_size(egui::vec2(260.0, 34.0));
let r = ui.add(discord).on_hover_text(crate::links::DISCORD);
@@ -724,7 +724,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
("Source code on GitHub", crate::links::GITHUB),
("ArtCraft — more creative apps", crate::links::WEBSITE),
] {
- let r = ui.link(label).on_hover_text(url);
+ let r = ui.link(crate::i18n::tr(label)).on_hover_text(url);
if r.clicked() {
let _ = crate::links::open(app, url);
}
@@ -775,7 +775,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Import { opts } => {
let n = opts.selected_paths().len();
let verb = if opts.copy && opts.move_files { "Move" } else { "Import" };
- add_label = format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" });
+ add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n);
add_label.as_str()
}
Dialog::Merge { .. } => "Merge",
diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs
index ff9d9a261..cddb984e1 100644
--- a/crates/ui-egui/src/panels/edit.rs
+++ b/crates/ui-egui/src/panels/edit.rs
@@ -140,8 +140,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim));
let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color");
- let r =
- crate::widgets::dropdown(ui, "profile", if name == "Color" { crate::i18n::tr("Color") } else { name }, t.font(15.0), t.text_label);
+ let r = crate::widgets::dropdown(ui, "profile", crate::i18n::tr(name), t.font(15.0), t.text_label);
egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() {
@@ -356,7 +355,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
key: &str,
p: &'static lightcraft_engine::presets::ProfileInfo,
pick: &mut (Option<&'static str>, Option<&'static str>)| {
- let r = ui.selectable_label(p.id == cur, p.name);
+ let r = ui.selectable_label(p.id == cur, crate::i18n::tr(p.name));
register(ui.ctx(), format!("profileMenu:{key}:{}", p.id), r.rect);
if r.clicked() {
pick.0 = Some(p.id);
@@ -366,7 +365,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
}
};
let heading = |ui: &mut egui::Ui, s: &str| {
- ui.label(egui::RichText::new(s).font(t.semibold(11.5)).color(t.text_dim));
+ ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim));
};
for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] {
let list: Vec<_> = ids.iter().filter_map(|id| profile(id)).collect();
@@ -380,7 +379,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
ui.separator();
}
for g in profile_groups() {
- let r = ui.menu_button(g, |ui| {
+ let r = ui.menu_button(crate::i18n::tr(g), |ui| {
ui.set_min_width(170.0);
for p in PROFILES.iter().filter(|p| p.group == g) {
item(ui, "group", p, &mut pick);
@@ -392,9 +391,9 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
if let Some(p) = profile(cur) {
let fav = app.session.profile_favorites.iter().any(|f| f == p.id);
let label = if fav {
- crate::i18n::tr_format!("Remove “{}” from Favorites", p.name)
+ crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::tr(p.name))
} else {
- crate::i18n::tr_format!("Add “{}” to Favorites", p.name)
+ crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::tr(p.name))
};
let r = ui.button(label);
register(ui.ctx(), "profileMenu:toggleFavorite", r.rect);
@@ -414,7 +413,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
let mut s = d.clone();
s.profile.id = p.id.to_string();
s.profile.amount = 100.0;
- app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s });
+ app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s });
}
if let Some(id) = pick.0 {
let _ = app.run("develop.profile", json!({"id": id}));
@@ -811,7 +810,9 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
ui.horizontal(|ui| {
ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label));
let current = matching_preset(&app.session, &d.curve);
- let r = crate::widgets::dropdown(ui, "curvePreset", current.as_deref().unwrap_or("Custom"), t.font(12.0), t.text);
+ let builtin = current.as_ref().is_none_or(|name| all_presets(&app.session).iter().any(|p| p.builtin && &p.name == name));
+ let label = crate::i18n::builtin_label(current.as_deref().unwrap_or("Custom"), builtin);
+ let r = crate::widgets::dropdown(ui, "curvePreset", label, t.font(12.0), t.text);
egui::Popup::menu(&r).show(|ui| {
ui.set_min_width(180.0);
let presets = all_presets(&app.session);
@@ -821,7 +822,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings)
user_seen = true;
ui.separator();
}
- let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), &p.name);
+ let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), crate::i18n::builtin_label(&p.name, p.builtin));
register(ui.ctx(), format!("curvePreset:{}", p.name), r.rect);
if r.clicked() {
let _ = app.run("curve.applyPreset", json!({"name": p.name}));
diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs
index 1c5a9a352..e83fec8b4 100644
--- a/crates/ui-egui/src/panels/grid.rs
+++ b/crates/ui-egui/src/panels/grid.rs
@@ -150,7 +150,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
folder_header(app, ui, hr, &b, &ids, local, &cnt)
}
None => {
- let title = app.session.source.label(&app.session.catalog);
+ let title = crate::i18n::source_label(app.session.source, &app.session.catalog);
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
}
@@ -164,7 +164,11 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if !chips.is_empty() {
let body = match total {
Some(n) if n > 0 => {
- crate::i18n::tr_format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog), n = n)
+ crate::i18n::tr_format!(
+ "{n} photos in {} are hidden by the filters above",
+ crate::i18n::source_label(app.session.source, &app.session.catalog),
+ n = n
+ )
}
_ => "Remove a filter above, or choose Clear all".to_string(),
};
@@ -409,7 +413,7 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids:
p.rect_filled(r, 0.0, t.canvas);
p.hline(r.x_range(), r.bottom(), Stroke::new(1.0, t.divider));
}
- let g = p.layout_no_wrap(run.label.clone(), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label });
+ let g = p.layout_no_wrap(crate::i18n::date_group_label(&run.key, false), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label });
let x = r.left() + 8.0;
let gw = g.size().x;
p.galley(pos2(x, r.center().y - g.size().y / 2.0), g, t.text);
@@ -520,7 +524,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
.collect();
if parts.is_empty() { "—".to_string() } else { parts.join(" · ") }
}
- "date" => photo.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "No date".into()),
+ "date" => photo.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| crate::i18n::tr("No date").into()),
_ => photo.file_name.rsplit_once('.').map(|(n, _)| n.to_string()).unwrap_or(photo.file_name.clone()),
};
p.text(pos2(r.left() + 8.0, r.top() + 12.0), Align2::LEFT_CENTER, name, t.font(10.5), t.text_dim);
@@ -786,7 +790,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.separator();
ui.menu_button(crate::i18n::tr("Set Rating"), |ui| {
for r in 0..=5 {
- if ui.button(if r == 0 { "No Stars".to_string() } else { "★".repeat(r) }).clicked() {
+ if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() {
let _ = app.run("photo.rate", json!({"rating": r}));
}
}
diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs
index 7d7037435..760eefde8 100644
--- a/crates/ui-egui/src/panels/left.rs
+++ b/crates/ui-egui/src/panels/left.rs
@@ -120,14 +120,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("By Date"), t.semibold(13.5), t.text_label);
for g in app.caches.date_groups(&app.session.catalog).iter() {
// year → month → day; a click filters by that prefix, the triangle opens a level
- if date_row(app, ui, &g.year, &g.year, g.count, 0.0) {
+ if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) {
for (m, n) in &g.months {
- let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string();
+ let label = crate::i18n::date_group_label(m, true);
if date_row(app, ui, m, &label, *n, 16.0) {
for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) {
- let label = lightcraft_catalog::dates::group_label(d);
- // "Sunday, 20 September 2026" → "Sunday, 20"
- let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string();
+ let label = crate::i18n::date_group_label(d, true);
date_row(app, ui, d, &label, *n, 32.0);
}
}
diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs
index 20a095e03..f60ddc5d6 100644
--- a/crates/ui-egui/src/panels/presets.rs
+++ b/crates/ui-egui/src/panels/presets.rs
@@ -85,6 +85,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
groups.entry(p.group.clone()).or_default().push(p.clone());
}
for (g, items) in groups {
+ // A group holding stock presets is a stock group (shown in the UI language), even
+ // when user presets share its name; a group of user presets keeps its name.
+ let builtin = items.iter().any(|p| p.builtin);
let open_id = egui::Id::new(("preset-group", &g));
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
@@ -95,7 +98,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_label,
);
- ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, &g, t.semibold(13.0), t.text_label);
+ ui.painter().text(
+ pos2(r.left() + 36.0, r.center().y),
+ Align2::LEFT_CENTER,
+ crate::i18n::builtin_label(&g, builtin),
+ t.semibold(13.0),
+ t.text_label,
+ );
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(open_id, !open));
}
@@ -108,7 +117,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
continue;
}
for pr in items {
- let (pid, name, fav) = (pr.id.clone(), pr.name.clone(), pr.favorite);
+ let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
register(ui.ctx(), format!("preset:{pid}"), r);
let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid);
@@ -156,7 +165,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
let (builtin, group) = (pr.builtin, pr.group.clone());
resp.context_menu(|ui| {
- if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() {
+ if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() {
let _ = app.run("preset.favorite", json!({"id": pid}));
}
if builtin {
@@ -168,7 +177,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
.clicked()
{
match app.run("preset.update", json!({"id": pid})) {
- Ok(_) => app.toast(ui.ctx(), format!("Updated “{name}”")),
+ Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)),
Err(e) => app.toast(ui.ctx(), e),
}
}
diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs
index 7633cdef6..ec4ddc57b 100644
--- a/crates/ui-egui/src/panels/profiles.rs
+++ b/crates/ui-egui/src/panels/profiles.rs
@@ -35,7 +35,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
let name = profile(&d.profile.id).map(|p| p.name).unwrap_or("Color");
ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Profile"), t.font(13.0), t.text_dim);
- ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, name, t.font(14.0), t.text_label);
+ ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr(name), t.font(14.0), t.text_label);
if d.profile.id != "lc.color" {
super::edit::control(app, ui, &d, "profile.amount", true);
ui.add_space(6.0);
@@ -60,7 +60,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if open { Icon::ChevronDown } else { Icon::ChevronRight },
t.text_label,
);
- ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label);
+ ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label);
ui.painter().text(pos2(r.right() - 22.0, r.center().y), Align2::RIGHT_CENTER, list.len().to_string(), t.font(12.0), t.text_dim);
if resp.clicked() {
ui.data_mut(|m| m.insert_temp(open_id, !open));
@@ -90,7 +90,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
&& let Some(p) = profile(&saved_profile_id)
{
let s = with_profile(&d, p);
- app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s });
+ app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s });
}
} else {
ui.data_mut(|m| m.remove::<(PhotoId, String)>(last_hover_id));
@@ -129,7 +129,7 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id:
painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside);
}
let name_color = if applied { t.text } else { t.text_label };
- painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, p.name, t.font(12.0), name_color);
+ painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, crate::i18n::tr(p.name), t.font(12.0), name_color);
// favourite star (always shown when set, on hover otherwise)
let fav = app.session.profile_favorites.iter().any(|f| f == p.id);
let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0));
@@ -140,10 +140,10 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id:
paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE });
}
if resp.hovered() && !applied && !over_star {
- app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s.clone() });
+ app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s.clone() });
ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p.id.to_string())));
}
- let resp = resp.on_hover_text(format!("{} ({})", p.name, p.group));
+ let resp = resp.on_hover_text(format!("{} ({})", crate::i18n::tr(p.name), crate::i18n::tr(p.group)));
if resp.clicked() {
if resp.interact_pointer_pos().is_some_and(|q| star.contains(q)) {
let _ = app.run("profile.favorite", json!({"id": p.id}));
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index 1a07b5c6b..6985e1c99 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -48,7 +48,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
pub fn header(ui: &mut egui::Ui, title: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
- ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, title, t.semibold(15.0), t.text);
+ ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(15.0), t.text);
}
pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) {
@@ -69,7 +69,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.horizontal(|ui| {
ui.label(crate::i18n::tr("Aspect Ratio"));
let cur = d.crop.aspect.map(|(w, h)| format!("{} × {}", w as f64 / 100.0, h as f64 / 100.0)).unwrap_or_else(|| "Free".into());
- let r = ui.add(egui::Button::new(cur).frame(false));
+ let r = ui.add(egui::Button::new(crate::i18n::tr(&cur)).frame(false));
register(ui.ctx(), "button:cropAspect", r.rect);
egui::Popup::menu(&r).show(|ui| {
for (label, a) in [
@@ -84,7 +84,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
("16 × 9", "16x9"),
("16 × 10", "16x10"),
] {
- if ui.button(label).clicked() {
+ if ui.button(crate::i18n::tr(label)).clicked() {
let _ = app.run("crop.aspect", json!({"aspect": a}));
}
}
@@ -494,7 +494,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() {
let _ = app.run("dialog.captureTime", json!({}));
}
- let cap = p.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "—".into());
+ let cap = p.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| "—".into());
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate());
});
@@ -651,7 +651,7 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr
fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) {
let t = Tokens::get(ui.ctx());
- ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim));
+ ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim));
let id = egui::Id::new(("info-field", key));
let mut text: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string());
let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) };
@@ -830,7 +830,8 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false);
let named_n = p.versions.iter().filter(|v| !v.auto).count();
let items = [("Named", "named"), ("Auto", "auto")];
- let labels = [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), format!("Auto ({})", p.versions.len() - named_n)];
+ let labels =
+ [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), crate::i18n::tr_format!("Auto ({})", p.versions.len() - named_n)];
let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect();
if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) {
auto = i == 1;
@@ -840,7 +841,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect();
if shown.is_empty() {
let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." };
- ui.label(egui::RichText::new(msg).color(t.text_dim));
+ ui.label(egui::RichText::new(crate::i18n::tr(msg)).color(t.text_dim));
}
// newest first
for (i, v) in shown.into_iter().rev() {
@@ -907,6 +908,10 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
/// "Sep 30, 2026, 12:00 PM" from an ISO time.
fn short_time(iso: &str) -> String {
+ // Other languages use their own date patterns (`i18n::display_time`).
+ if crate::i18n::language() != crate::i18n::Locale::En {
+ return crate::i18n::display_time(iso);
+ }
let long = lightcraft_catalog::dates::display_time(iso);
// "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped
let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t)));
@@ -935,7 +940,13 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.painter().rect_filled(r, 3.0, t.hover);
}
paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon);
- ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, &h.label, t.font(12.5), t.text_label);
+ ui.painter().text(
+ pos2(r.left() + 24.0, r.center().y),
+ Align2::LEFT_CENTER,
+ crate::i18n::history_label(&h.label, &app.session.presets),
+ t.font(12.5),
+ t.text_label,
+ );
if resp.clicked() {
let _ = app.run("history.restore", json!({"index": i}));
}
diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md
index 2da811bb4..002232b11 100644
--- a/docs/localization-zh-hans.md
+++ b/docs/localization-zh-hans.md
@@ -13,7 +13,7 @@ LightCraft 的界面支持简体中文、日语和英语。语言设置保存在
- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
(单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。
原生桌面版不受影响。
-- 日期分组标题(如 `Sunday, 20 September 2026`)沿用英文格式,尚未本地化。
+- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。
## 翻译的维护
diff --git a/docs/localization-zh-hant.md b/docs/localization-zh-hant.md
new file mode 100644
index 000000000..349cb20e0
--- /dev/null
+++ b/docs/localization-zh-hant.md
@@ -0,0 +1,46 @@
+# LightCraft 繁體中文(台灣)介面
+
+從「編輯 → 語言 → 繁體中文(台灣)」切換,或開啟「設定 → 一般 → 語言」選擇。
+macOS 的設定也可由「LightCraft → 設定…」或 `⌘,` 開啟。
+語言立即生效,並儲存在應用程式設定的 `ui.json`,下次啟動會沿用。
+英文、簡體中文、繁體中文與日文可隨時互相切換。
+
+繁中翻譯涵蓋 macOS 原生選單、程式內選單與子選單、鍵盤快速鍵說明、照片編輯、
+裁切、遮色片、匯入/匯出、設定、41 個內建編輯預設集與分類、內建描述檔、
+曲線與匯出預設集、歷史紀錄/版本面板、內建照片來源標題、日期群組與拍攝時間、
+照片庫開啟失敗提示及主要處理進度。
+切換介面不會變更操作指令 ID、照片檔名、使用者命名的相簿、預設集或中繼資料。
+未翻譯的技術錯誤、發行說明及部分新增文案仍使用英文。
+
+## 建置與字型
+
+使用既有 [craft-fonts](https://github.com/storytold/craft-fonts) 字型作為建置輸入:
+
+```sh
+CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
+```
+
+craft-fonts 目前沒有繁體中文專用字型:桌面版先以 Noto Sans CJK SC 顯示繁中文字(字形為大陸規範寫法),其次才是日文字型;不在此儲存庫新增字型檔。
+未指定 `CRAFT_FONTS_DIR` 的建置缺少 CJK 字形。
+Web 建置僅嵌入 BIZ UDPGothic Regular,尚未驗證完整繁中文字形覆蓋。
+
+## 自動化與維護
+
+`LIGHTCRAFT_LANGUAGE=zh-hant lightcraft-cli snapshot --demo -o zh-hant.png` 可繪製繁中畫面。
+接受 `zh-hant`、`zh-Hant`、`zh-TW`、`zh_TW.UTF-8` 與 `zh-HK`;儲存時統一使用 `zh-hant`。
+控制通道可切換語言:
+
+```json
+{"method":"ui.set","params":{"language":"zh-hant"}}
+{"method":"ui.menu.invoke","params":{"command":"app.language.traditionalChinese"}}
+```
+
+靜態文案位於 `crates/ui-egui/locales/zh-hant.json`,含變數的訊息位於
+`crates/ui-egui/locales/zh-hant-formats.json`。動態訊息由 Rust 在建置時檢查格式參數,
+所有 `*-formats.json` 須保持相同英文鍵。新增語言的方法見 [`localization.md`](localization.md)。`{:.0}` 消耗英文複數字尾引數,繁中不顯示該字尾。
+
+```sh
+CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests
+```
+
+測試涵蓋翻譯目錄、指令/選單文字、語言切換、設定保存、資料名稱保持原樣及桌面字形。
diff --git a/docs/localization.md b/docs/localization.md
index 3eb5efcaa..f6db12ffe 100644
--- a/docs/localization.md
+++ b/docs/localization.md
@@ -1,22 +1,28 @@
# 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).
+`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
+Chinese (`zh-hant`, Taiwan) 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), [`localization-zh-hant.md`](localization-zh-hant.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.
+ English source strings and must stay byte-for-byte identical. The plain catalog may grow at its
+ own pace: a message it lacks shows in English, and `cargo test -p lightcraft-ui-egui i18n::tests
+ -- --nocapture` lists the gaps (it fails on an empty entry or mismatched placeholders). The
+ formats catalog must carry every message (the build fails otherwise), including the date
+ patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and
+ capture times use in every language but English; weekday names go in the plain catalog.
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");
+ ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
}
```
diff --git a/docs/parity.md b/docs/parity.md
index fc9226782..63f0cb512 100644
--- a/docs/parity.md
+++ b/docs/parity.md
@@ -503,8 +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-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-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `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-zh-hant.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan) 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. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Traditional Chinese catalog is the most complete, the others fall back to English for messages they lack. Technical errors/release notes remain English; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), 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 | ⬜ | | |