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 | ⬜ | | |