diff --git a/Cargo.lock b/Cargo.lock index 1952f5c4f..aa91c3d9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2095,6 +2095,7 @@ version = "0.2.1" dependencies = [ "crc32fast", "lightcraft-develop", + "lightcraft-meta", "log", "proptest", "serde", @@ -2348,6 +2349,7 @@ dependencies = [ "lightcraft-develop", "lightcraft-engine", "lightcraft-geom", + "lightcraft-meta", "lightcraft-pipeline", "lightcraft-preview", "lightcraft-raster", diff --git a/README.md b/README.md index e7454f249..9692b6360 100644 --- a/README.md +++ b/README.md @@ -334,16 +334,19 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light cargo xtask ci # fmt, clippy, tests, layering, wasm checks ``` -**Japanese text** needs the shared font repo, an optional build input (official releases always include it): +**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it): ```sh git clone https://github.com/storytold/craft-fonts ../craft-fonts CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft ``` -Without it LightCraft builds and runs the same, but Japanese text has no glyphs. Fonts are never committed to this +Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). +**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). @@ -432,7 +435,7 @@ Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel (1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA, *Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font: Inter (SIL OFL 1.1). Builds made with [craft-fonts](https://github.com/storytold/craft-fonts) (all official releases) -also embed its Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its +also embed its Chinese and Japanese fonts (Noto Sans CJK SC, BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its [ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). All icons are original. The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the @@ -446,3 +449,7 @@ Forks and modified versions must remove them. ArtCraft
Made by the ArtCraft team and community.

+ +## Star history + +[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left) diff --git a/ROADMAP.md b/ROADMAP.md index c1cc9d8a7..fdfbec0cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,21 +23,21 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Estimate | What's true today | Biggest gaps | |---|---:|---|---| | **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | -| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) | -| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW has a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | +| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~40 corpus files vs >1,000 models) | +| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW and NEF have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | | **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | | **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export | -| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load | +| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, 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 | User | Readiness | What blocks them | |---|---:|---| | JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks | -| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW preview estimates are only a starting point) | -| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only | +| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW and NEF preview estimates are only a starting point) | +| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) | | Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering | | Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models | @@ -46,8 +46,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*. 1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use - matrices the files carry themselves, then chart shots. Sony ARW's file-local fit (matrix + tone curve from its own - JPEG) is the first step; generalise it to the other makes' raws (NEF, RW2, PEF, ORF…), then validate fidelity. + matrices the files carry themselves, then chart shots. Sony ARW and Nikon NEF have the file-local fit (matrix + tone + curve from their own JPEG); generalise it to the other makes' raws (RW2, PEF, ORF…), then validate fidelity. 2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source, no LGPL dependency); compressed NEF (#86) is the template. @@ -82,12 +82,12 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help | M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) | | M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ | | M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) | -| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) | +| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW and NEF preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) | | M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ | | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | | M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) | -| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Japanese/English localisation 🟡; accessibility and other locales ⬜) | +| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) | ## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02) @@ -192,12 +192,14 @@ own chart-based calibration (M11.4). Adobe matrices are never used. export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added tracker rows for camera colour, camera coverage and render fidelity. -## Japanese interface and text watermarks +## Chinese and Japanese interfaces, and text watermarks -English/Japanese interface language is persisted in UI state. Core menus have Japanese -translations; untranslated panels and dialogs retain English. Japanese glyphs (UI: BIZ UDPGothic; -watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made with the -optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept +English, Simplified Chinese, 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: +BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made +with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept `vertical: true` in export JSON/presets and expose an orientation selector. Japanese characters stay upright in top-to-bottom columns, with newlines starting columns to the left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 719e5f10b..b9e2641c9 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -87,7 +87,7 @@ USAGE: lightcraft-cli controls [--json] list every develop control id with its range lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES… Fit a colour profile per camera model from raw files and their embedded camera JPEGs - (Sony ARW): up to N files spread over the folders (default 300; 0 = all), pooled per + (Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per model, written as .json to DIR (default: the profiles folder LightCraft reads, /camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then take their colour from the profile and only their tone from their own JPEG. @@ -154,7 +154,7 @@ fn main() -> ExitCode { /// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders. fn raw_files(path: &Path, out: &mut Vec, depth: usize) { - let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("arw")); + let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x))); if path.is_file() { if is_raw(path) { out.push(path.to_path_buf()); @@ -532,39 +532,39 @@ fn run(args: &[String]) -> Result<(), String> { if steps.is_empty() { return Err("run: no command given (see `lightcraft-cli commands`)".into()); } - let mut backend: Box = match connect { - Some(addr) => { - if demo || library.is_some() || !imports.is_empty() { - return Err("--demo, --library and --import apply to headless mode only".into()); - } - Box::new( - Remote::connect(&addr) - .map_err(|e| format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control 7980`"))?, - ) - } - None => { - let mut h = match &library { - Some(dir) => { - let mut h = Headless::default(); - h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?; - h - } - None if demo => Headless::demo(), - None => Headless::default(), - }; - if !imports.is_empty() { - let paths = expand_paths(&imports); - let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; - // what follows acts on the imported photos (already-known files are reported as duplicates) - let mut ids: Vec = r["imported"].as_array().cloned().unwrap_or_default(); - ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned())); - if let Some(first) = ids.first().cloned() { - h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?; + let mut backend: Box = + match connect { + Some(addr) => { + if demo || library.is_some() || !imports.is_empty() { + return Err("--demo, --library and --import apply to headless mode only".into()); } + Box::new(Remote::connect(&addr).map_err(|e| { + format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr)) + })?) } - Box::new(h) - } - }; + None => { + let mut h = match &library { + Some(dir) => { + let mut h = Headless::default(); + h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?; + h + } + None if demo => Headless::demo(), + None => Headless::default(), + }; + if !imports.is_empty() { + let paths = expand_paths(&imports); + let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; + // what follows acts on the imported photos (already-known files are reported as duplicates) + let mut ids: Vec = r["imported"].as_array().cloned().unwrap_or_default(); + ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned())); + if let Some(first) = ids.first().cloned() { + h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?; + } + } + Box::new(h) + } + }; let mut out = std::io::stdout().lock(); let mut failed = 0; for s in &steps { @@ -590,6 +590,12 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value")) } +/// The port an `--connect` address names, for the recovery hint. The whole address when it has +/// no port, so a hint is never built from a guess. +fn connect_port(addr: &str) -> &str { + addr.rsplit_once(':').map_or(addr, |(_, port)| port) +} + fn render(args: &[String]) -> Result<(), String> { let mut input = None; let mut output = None; diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 9d41873a4..37799f4aa 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -324,3 +324,13 @@ fn a_library_open_in_another_process_is_refused() { assert_eq!(lines[0]["ok"], true); let _ = std::fs::remove_dir_all(&lib); } + +/// Issue #167: when `--connect ADDR` cannot reach the app, the recovery hint must name the port +/// ADDR actually names — following the old fixed "7980" left the retry connecting elsewhere. +#[test] +fn connect_failure_hint_names_the_attempted_port() { + let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None); + assert!(!ok); + assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}"); + assert!(!err.contains("--control 7980"), "{err}"); +} diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 8af250a5b..557c9abfb 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec)]) -> String { out.push(';'); } } - let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese()); + let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code()); for (t, items) in bar { s.push_str(t); walk(items, &mut s); @@ -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/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 475083596..c70c2831c 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lightcraft-develop = { workspace = true } +lightcraft-meta = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index aad5949bc..097a71790 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -35,7 +35,7 @@ use lightcraft_develop::DevelopSettings; pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of}; pub use lock::{LibraryLock, LockError, LockOwner}; pub use model::*; -pub use query::{DateGroup, Filter, RatingOp, Sort, SortKey}; +pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey}; pub use rules::{Match, Rule, RuleSet}; use serde::{Deserialize, Serialize}; pub use store::{FsStore, MemStore, Store}; diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 8b400bb52..9248b2206 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -183,6 +183,11 @@ pub struct Meta { pub copyright_url: String, pub creator: String, pub keywords: Vec, + /// Face/pet/focus regions read from XMP (MWG-RS), on the upright (EXIF-oriented) photo. + /// Removing or resizing one edits the catalog only; LightCraft never writes regions to XMP. + /// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub regions: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -331,10 +336,11 @@ impl Photo { pub fn develops_raw(&self) -> bool { self.kind == MediaKind::Raw && self.preview_only.is_none() } - /// The current ARW reader has vendor WB multipliers but no measured camera illuminant. - /// Use adjustments relative to the camera's as-shot look, as for rendered photographs. + /// The current ARW and NEF readers have vendor WB multipliers but no measured camera + /// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered + /// photographs (the engine's `camera_preview::file_local_look` covers the same formats). pub fn relative_wb(&self) -> bool { - self.develops_raw() && self.format.eq_ignore_ascii_case("ARW") + self.develops_raw() && ["ARW", "NEF", "NRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's /// default preset applied on top of them ([`Photo::import_look`]). @@ -421,4 +427,19 @@ mod edited_tests { p.develop = Arc::new(DevelopSettings::default()); assert!(!p.is_edited(), "a full reset is unedited too"); } + + #[test] + fn sony_and_nikon_raws_use_relative_white_balance() { + for (name, format, relative) in [("a.arw", "ARW", true), ("a.nef", "NEF", true), ("a.nrw", "nrw", true), ("a.dng", "DNG", false)] { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); + p.kind = MediaKind::Raw; + p.as_shot_wb = Some((5200.0, 4.0)); + assert_eq!(p.relative_wb(), relative, "{format}"); + let wb = p.camera_defaults().wb; + assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}"); + // shown from the embedded preview: a rendered image, not a relative-WB raw + p.preview_only = Some("unsupported".into()); + assert!(!p.relative_wb()); + } + } } diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 72c7ffb1b..384c2ae55 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -39,6 +39,8 @@ pub struct Filter { pub date: Option, /// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`). pub keyword: Option, + /// A person: photos with a named face region of this name (case-insensitive), as read from XMP. + pub person: Option, pub camera: Option, /// Lens (case-insensitive substring). pub lens: Option, @@ -113,6 +115,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "camera" => p.meta.camera.to_lowercase().contains(val), "lens" => p.meta.lens.to_lowercase().contains(val), "keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)), + "person" | "who" => has_person(p, val), "type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val), "edited" => (val == "true" || val == "yes") == p.is_edited(), "date" => p.date().starts_with(val), @@ -125,6 +128,12 @@ fn token_matches(p: &Photo, tok: &str) -> bool { hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t)) } +/// Whether `p` has a named face region called `name` (case-insensitive). +fn has_person(p: &Photo, name: &str) -> bool { + let name = name.trim().to_lowercase(); + p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name)) +} + impl Filter { /// Human-readable summary of the active rules (smart album tooltips, `album.list`). pub fn describe(&self) -> String { @@ -152,9 +161,14 @@ impl Filter { if let Some(e) = self.edited { v.push(if e { "edited".into() } else { "unedited".into() }); } - for (name, val) in - [("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)] - { + for (name, val) in [ + ("keyword", &self.keyword), + ("person", &self.person), + ("camera", &self.camera), + ("lens", &self.lens), + ("date", &self.date), + ("imported", &self.imported), + ] { if let Some(x) = val { v.push(format!("{name} {x}")); } @@ -267,6 +281,11 @@ impl Filter { { return false; } + if let Some(n) = &self.person + && !has_person(p, n) + { + return false; + } if let Some(c) = &self.camera && !p.meta.camera.eq_ignore_ascii_case(c) { @@ -330,6 +349,54 @@ impl Catalog { } m.into_iter().collect() } + + /// The people named on faces in the library (MWG regions read from XMP): how many photos each + /// appears in and the photo showing their largest face (for a card's picture). Most photos first, + /// then by name. Names that differ only in case are one person, shown as first seen; a person + /// twice in one photo counts once. + pub fn people(&self) -> Vec { + self.people_in(&Filter::default()) + } + + /// [`Self::people`] among the photos `filter` lets through (its `person` is ignored): what the + /// People view offers while other filters (a date, a rating, an album…) are active, so picking + /// a person never ends in an empty grid. + pub fn people_in(&self, filter: &Filter) -> Vec { + let filter = Filter { person: None, ..filter.clone() }; + let mut m: std::collections::HashMap = Default::default(); + for p in self.photos().filter(|p| filter.matches(p, self)) { + let mut seen: Vec = Vec::new(); + for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { + let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = name.to_lowercase(); + // the face's size in pixels of the photo + let area = (r.rect.x1 - r.rect.x0) * (r.rect.y1 - r.rect.y0) * p.width as f64 * p.height as f64; + let (person, best) = + m.entry(key.clone()).or_insert_with(|| (Person { name: name.to_string(), count: 0, photo: p.id, face: r.rect }, area)); + if !seen.contains(&key) { + person.count += 1; + seen.push(key); + } + if area > *best || (area == *best && p.id < person.photo) { + (person.photo, person.face, *best) = (p.id, r.rect, area); + } + } + } + let mut v: Vec = m.into_values().map(|(p, _)| p).collect(); + v.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + v + } +} + +/// A person named on faces in the library ([`Catalog::people`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Person { + pub name: String, + /// Photos they appear in. + pub count: usize, + /// The photo with their largest face, and that face (normalized, in the photo's upright frame). + pub photo: PhotoId, + pub face: lightcraft_meta::Rect, } #[derive(Clone, Debug, PartialEq, Serialize)] diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 86442d2a9..6f76b694e 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -297,3 +297,86 @@ fn folder_identity_ignores_spelling() { assert!(folder_within("/a/b", "/")); assert!(folder_within("C:\\x", "c:\\")); } + +/// People come from named *face* regions: counted once per photo, case-insensitive, most photos +/// first; pets and unnamed faces are not people; the `person` filter and `person:` token match. +#[test] +fn people_from_named_face_regions() { + use lightcraft_meta::{Rect, Region, RegionKind}; + let region = |name: Option<&str>, kind: RegionKind| Region { + rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind, + name: name.map(str::to_string), + description: None, + }; + let mut c = Catalog::new(); + let mut add = |name: &str, regions: Vec| { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00"); + p.meta.regions = regions; + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let a = add( + "a.jpg", + vec![region(Some("Jane Doe"), RegionKind::Face), region(Some("jane doe"), RegionKind::Face), region(Some("Rex"), RegionKind::Pet)], + ); + let b = + add("b.jpg", vec![region(Some("JANE DOE"), RegionKind::Face), region(Some("John Roe"), RegionKind::Face), region(None, RegionKind::Face)]); + let big = Rect { x0: 0.2, y0: 0.2, x1: 0.8, y1: 0.8 }; + let d = add("d.jpg", vec![Region { rect: big, ..region(Some("John Roe"), RegionKind::Face) }]); + let e = add("e.jpg", vec![region(Some("Sam"), RegionKind::Face)]); + add("f.jpg", vec![]); + let people = c.people(); + let summary: Vec<(&str, usize)> = people.iter().map(|p| (p.name.as_str(), p.count)).collect(); + assert_eq!(summary, vec![("Jane Doe", 2), ("John Roe", 2), ("Sam", 1)], "once per photo, pets and unnamed faces left out"); + // the picture is the person's largest face; ties go to the lower photo id + assert_eq!((people[0].photo, people[0].face), (a, Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 })); + assert_eq!((people[1].photo, people[1].face), (d, big), "the larger face wins over an earlier, smaller one"); + + let q = |f: Filter| c.query(&f, &Sort::default()); + assert_eq!(q(Filter { person: Some("jane doe".into()), ..Default::default() }).len(), 2); + let mut got = q(Filter { person: Some("John Roe".into()), ..Default::default() }); + got.sort(); + assert_eq!(got, vec![b, d]); + assert!(q(Filter { person: Some("Rex".into()), ..Default::default() }).is_empty(), "a pet is not a person"); + assert_eq!(q(Filter { text: "person:SAM".into(), ..Default::default() }), vec![e], "the search token finds a person, any case"); + assert!(q(Filter { person: Some("Jane Doe".into()), ..Default::default() }).contains(&a)); + + // other filters narrow who is offered (so picking a person never ends in an empty grid); the + // `person` filter itself does not + c.apply(Op::SetRating { id: b, rating: 3 }).unwrap(); + let names = |f: Filter| c.people_in(&f).into_iter().map(|p| (p.name, p.count)).collect::>(); + let rated = Filter { rating: 1, ..Default::default() }; + assert_eq!( + names(rated.clone()), + vec![("JANE DOE".to_string(), 1), ("John Roe".to_string(), 1)], + "only the rated photo's people, counted within it, spelled as first seen there" + ); + assert_eq!( + names(Filter { person: Some("Sam".into()), ..rated }), + names(Filter { rating: 1, ..Default::default() }), + "the person filter is ignored" + ); + assert_eq!(names(Filter { rating: 5, ..Default::default() }), vec![], "nobody in the filtered photos"); +} + +/// Most photos have no regions: the field is left out of the catalog JSON then, and a catalog written +/// before regions existed (no `regions` key) reads with none. +#[test] +fn empty_regions_are_not_serialized_and_default_when_missing() { + let m = Meta { keywords: vec!["k".into()], ..Default::default() }; + let v = serde_json::to_value(&m).unwrap(); + assert!(v.get("regions").is_none(), "{v}"); + let back: Meta = serde_json::from_value(v).unwrap(); + assert!(back.regions.is_empty()); + let mut with = m.clone(); + with.regions.push(lightcraft_meta::Region { + rect: lightcraft_meta::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some("A".into()), + description: None, + }); + let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap(); + assert_eq!(back, with); +} diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index da4fbccb9..f01206411 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1,5 +1,6 @@ -//! Estimate an ARW starting look from its own JPEG. Colour and luminance are fitted separately; -//! the JPEG supplies correspondences only, never output pixels or a replacement for RAW editing. +//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF) from its +//! own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only, +//! never output pixels or a replacement for RAW editing. //! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a //! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals //! (applied like a DNG `ProfileHueSatMap`) corrects that when it also improves the held-out pixels. @@ -9,7 +10,7 @@ use lightcraft_raster::{ Rgb32f, resample::{Filter, fit}, }; -use lightcraft_raw::{RawImage, color::CameraTransform, profile::HsvTable}; +use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable}; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -25,8 +26,16 @@ const PROXY: usize = 96; /// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples. pub(crate) const PROFILE_PROXY: usize = 192; +/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix: +/// their starting look is fitted to the file's own JPEG, and white balance is relative to the +/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the +/// same formats by file extension. +pub(crate) fn file_local_look(format: RawFormat) -> bool { + matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw) +} + pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { - if !transform.matrix_is_fallback || raw.format != lightcraft_raw::RawFormat::Arw { + if !transform.matrix_is_fallback || !file_local_look(raw.format) { return None; } let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?; @@ -37,7 +46,8 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf let look = fit_pairs_with(&sensor, &reference, colour)?; if lightcraft_pipeline::profiling() { eprintln!( - "[profile] ARW camera look: {:?}, {:?}, hue/sat table {}, camera profile {}", + "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile {}", + raw.format, look.matrix.0, look.tone, look.hue_sat.is_some(), @@ -72,10 +82,11 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz Some((sensor, reference)) } -/// Colour training pairs of one ARW for a camera profile: white-balanced camera RGB (with the -/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for other files or unusable previews. +/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the +/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own +/// colour matrices, other files or unusable previews. pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option> { - if raw.format != lightcraft_raw::RawFormat::Arw || lightcraft_raw::color::has_matrix(&raw.color) { + if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { return None; } let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); @@ -240,7 +251,11 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt }) .sum(); if lightcraft_pipeline::profiling() { - eprintln!("[profile] ARW holdout RMS {:.5} -> {:.5} ({samples} channels)", (before / samples as f64).sqrt(), (after / samples as f64).sqrt()); + eprintln!( + "[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)", + (before / samples as f64).sqrt(), + (after / samples as f64).sqrt() + ); } if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) { return None; @@ -715,6 +730,29 @@ mod tests { assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples"); } + #[test] + fn sony_and_nikon_raws_get_a_file_local_look() { + assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw].into_iter().all(file_local_look)); + assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look)); + } + + /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white + /// balance is relative to the as-shot look. + #[test] + fn corpus_nef_gets_a_camera_look() { + let path = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw/nef-nikon-d7500-lossless14.nef"); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + return; + }; + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert!(info.camera_tone.is_some(), "no camera look fitted"); + assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); + } + #[test] fn rejects_monochrome_invalid_and_unrelated_previews() { let mut sensor = Rgb32f::new(32, 32); diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index a936163ce..d2a9f16b2 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -1,6 +1,6 @@ //! Camera colour profiles of our own, pooled from many photos of one camera model. //! -//! A raw file without colour matrices (Sony ARW) gets its look fitted to its own embedded camera +//! A raw file without colour matrices (Sony ARW, Nikon NEF) gets its look fitted to its own embedded camera //! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a //! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate` //! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value @@ -174,8 +174,8 @@ pub struct Pool { const PAIRS_PER_FILE: usize = 4000; impl Pool { - /// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW - /// without colour matrices, no usable camera JPEG, too little colour). + /// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or + /// NEF without colour matrices, no usable camera JPEG, too little colour). pub fn add(&mut self, bytes: &[u8]) -> Result, String> { let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?; let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) }; diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 1641d63f1..e946d1fa9 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -183,6 +183,33 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo }) } +/// Ops that fill a photo's empty camera fields (camera, lens, exposure, GPS, capture time) from a fresh probe. +fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec { + let (mut m, src) = (ph.meta.clone(), &info.meta); + let before = m.clone(); + if m.camera.is_empty() { + m.camera = src.camera.clone(); + } + if m.lens.is_empty() { + m.lens = src.lens.clone(); + } + if m.shutter.is_empty() { + m.shutter = src.shutter.clone(); + } + m.focal_mm = m.focal_mm.or(src.focal_mm); + m.aperture = m.aperture.or(src.aperture); + m.iso = m.iso.or(src.iso); + m.gps = m.gps.or(src.gps); + let mut ops = Vec::new(); + if m != before { + ops.push(Op::SetMeta { id, meta: Box::new(m) }); + } + if ph.captured.is_none() && info.captured.is_some() { + ops.push(Op::SetCaptured { id, captured: info.captured.clone() }); + } + ops +} + /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { @@ -199,8 +226,18 @@ fn reload(s: &mut Session, p: &Value) -> Result { let mut reloaded = Vec::new(); for ((id, _), info) in paths.into_iter().zip(probed) { let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue }; - let Some(op) = content_op(id, ph, info.clone()) else { continue }; + // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; + // nothing already set is overwritten + let meta_ops = fill_missing_meta(id, ph, &info); + let Some(op) = content_op(id, ph, info.clone()) else { + if !meta_ops.is_empty() { + ops.extend(meta_ops); + reloaded.push(id); + } + continue; + }; ops.push(op); + ops.extend(meta_ops); // virtual copies share the file for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { ops.extend(content_op(c.id, c, info.clone())); @@ -277,9 +314,9 @@ pub fn edit_specs() -> Vec { cmd!( "photo.reload", "Reload from Disk", - [], + ["Photo"], None, - "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}", + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}", has_selection, reload ), @@ -294,3 +331,32 @@ pub fn edit_specs() -> Vec { ), ] } + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_catalog::{Photo, PhotoId, Source}; + + /// Reload fills camera fields a photo lacks (a CR3 imported before CR3 metadata was read) and keeps + /// everything already set. + #[test] + fn reload_fills_only_missing_camera_fields() { + let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.cr3".into() }, "x.cr3", "CR3", 1620, 1080, "2026-10-06T00:00:00"); + ph.meta.title = "Mine".into(); + ph.meta.iso = Some(800); + let mut info = crate::media::ProbeInfo { captured: Some("2026-10-04T08:16:11".into()), ..Default::default() }; + info.meta.camera = "Canon EOS R6 Mark III".into(); + info.meta.lens = "RF24-105mm".into(); + info.meta.iso = Some(400); + info.meta.aperture = Some(6.3); + let ops = fill_missing_meta(PhotoId(1), &ph, &info); + let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") }; + assert_eq!((meta.camera.as_str(), meta.lens.as_str(), meta.aperture), ("Canon EOS R6 Mark III", "RF24-105mm", Some(6.3))); + assert_eq!((meta.title.as_str(), meta.iso), ("Mine", Some(800)), "set fields are kept"); + assert!(matches!(ops.get(1), Some(Op::SetCaptured { captured: Some(_), .. }))); + // nothing missing → nothing to do + ph.meta = (**meta).clone(); + ph.captured = Some("2026-10-04T08:16:11".into()); + assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); + } +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 74e5d5b0f..841006387 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -263,7 +263,7 @@ pub fn specs() -> Vec { "Filter", [], None, - "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, camera?}", + "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, person?, camera?}", always, |s, p| { let mut v = serde_json::to_value(&s.filter).unwrap_or_default(); @@ -446,6 +446,66 @@ pub fn specs() -> Vec { cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)), cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)), cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)), + // ---- face / pet regions + cmd!( + "photo.removeRegion", + "Remove Face Box", + [], + None, + "{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back", + always, + |s, p| { + let id = + p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|i| usize::try_from(i).ok()) + .ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?; + let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone(); + if index >= meta.regions.len() { + return Err(bad("photo.removeRegion", "no such region")); + } + let gone = meta.regions.remove(index); + s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; + s.skip_auto_write = true; + Ok(json!({"removed": gone.name})) + } + ), + cmd!( + "photo.setRegion", + "Resize Face Box", + [], + None, + "{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar", + always, + |s, p| { + const C: &str = "photo.setRegion"; + // a drag is one undo step however many frames it took: the caller commits once, on release + let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?; + let index = p + .get("index") + .and_then(Value::as_u64) + .and_then(|i| usize::try_from(i).ok()) + .ok_or_else(|| bad(C, "missing or invalid `index`"))?; + let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?; + let num = |k: &str| { + r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number"))) + }; + let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?); + let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0)); + let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0)); + if x1 - x0 < 0.005 || y1 - y0 < 0.005 { + return Err(bad(C, "the box would be too small")); + } + let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone(); + let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?; + region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 }; + s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?; + s.skip_auto_write = true; + Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}})) + } + ), // ---- delete / restore cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?; @@ -453,11 +513,11 @@ pub fn specs() -> Vec { s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); Ok(v) }), - cmd!("photo.restore", "Restore", [], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { + cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { id, deleted: false }))), - cmd!("photo.deletePermanently", "Delete Permanently", [], None, "{ids?}", has_selection, |s, p| { + cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let t = s.targets(p); let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::>(); s.commit("Delete Permanently", Op::Batch { ops })?; diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index ab0d8edf0..6affb8b4d 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -245,14 +245,15 @@ impl Default for Watermark { /// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); -/// The watermark faces: Inter first, then the craft-fonts Japanese faces (Mincho first, the -/// watermark's serif look; then any other Japanese face). Without craft-fonts that is Inter alone, -/// and Japanese characters draw as Inter's missing-glyph box. +/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's +/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is +/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's +/// missing-glyph box. fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec> { - let mut japanese: Vec<_> = crate::fonts::japanese(craft).collect(); - japanese.sort_by_key(|f| !f.is_mincho()); + let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); + cjk.sort_by_key(|f| !f.is_mincho()); std::iter::once(WATERMARK_FONT) - .chain(japanese.into_iter().map(|f| f.bytes)) + .chain(cjk.into_iter().map(|f| f.bytes)) .filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok()) .collect() } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 6f8d18e15..1a34efac3 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -38,6 +38,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { copyright_url: m.copyright_url.clone().unwrap_or_default(), creator: m.artist.clone().unwrap_or_default(), keywords: m.keywords.clone(), + regions: m.regions.clone(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } @@ -132,7 +133,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { } let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw)); // Vendor RGB multipliers do not identify an absolute illuminant without camera calibration. - let relative = raw.format == lightcraft_raw::RawFormat::Arw && !lightcraft_raw::color::has_matrix(&raw.color); + let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color); let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) }); let embedded_lens = embedded_lens(&raw); return Ok(ProbeInfo { @@ -288,7 +289,7 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result< eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); } let (temp, tint) = xy_to_temp_tint(xy); - let relative = raw.format == lightcraft_raw::RawFormat::Arw && t.matrix_is_fallback; + let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback; let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone })); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 954d92631..97a830639 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,9 +1,9 @@ //! Fonts from the optional craft-fonts build input (storytold/craft-fonts). //! //! Built with `CRAFT_FONTS_DIR=`, [`CRAFT_FONTS`] holds every font in its -//! manifest (today: the Japanese UI and document fonts); otherwise it is empty and LightCraft uses -//! only its own bundled fonts (Inter). Both the egui UI and the export watermark renderer read it -//! from here. See craftrules `standards/fonts.md`. +//! manifest (today: the Japanese and Simplified-Chinese UI and document fonts); otherwise it is +//! empty and LightCraft uses only its own bundled fonts (Inter). Both the egui UI and the export +//! watermark renderer read it from here. See craftrules `standards/fonts.md`. /// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). pub struct CraftFont { @@ -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). @@ -33,6 +40,43 @@ pub fn japanese(fonts: &'static [CraftFont]) -> impl Iterator impl Iterator { + fonts.iter().filter(|f| f.scripts.iter().any(|script| is_cjk_script(script))) +} + +/// Whether an ISO 15924 script is written with Han, kana or hangul characters (Unicode's CJK +/// scripts), which Inter has no glyphs for. +pub fn is_cjk_script(script: &str) -> bool { + matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang") +} + +/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script +/// first (for `style` when asked), then every other CJK face. One CJK face list serves every +/// language, but Han characters are shared between them: a Chinese reader must get the Chinese +/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first. +/// Empty without craft-fonts. +pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> { + let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect(); + faces.sort_by_key(|font| { + ( + // The language's own script first. A face dedicated to the script is preferred over a + // multi-script face (Noto Sans CJK SC claims `Hans,Latn`; a Japanese face claims `Jpan`). + !font.serves(script), + font.scripts.len() != 1, + font.family != "BIZ UDPGothic", + font.style != style, + font.is_mincho(), + ) + }); + faces +} + +/// Whether the font is for any CJK script. +fn covers_cjk(font: &CraftFont) -> bool { + font.scripts.iter().any(|script| is_cjk_script(script)) +} + #[cfg(test)] mod tests { use super::*; @@ -49,4 +93,23 @@ mod tests { } assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces"); } + + /// Each language's own faces come first, so shared Han keeps the language's forms, and Latin + /// text still reaches Inter before any CJK fallback. + #[test] + fn a_language_gets_its_own_script_first() { + static FACES: &[CraftFont] = &[ + CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] }, + CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] }, + CraftFont { family: "BIZ UDMincho", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] }, + ]; + let first = |script: &str| cjk_fallback(FACES, script, "Regular").first().map(|f| f.family); + assert_eq!(first("Hans"), Some("Noto Sans CJK SC")); + assert_eq!(first("Jpan"), Some("BIZ UDPGothic")); + // 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/engine/src/lib.rs b/crates/engine/src/lib.rs index 9455ea49a..05a9cc901 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -126,6 +126,9 @@ pub struct Session { pub undo: Vec, pub redo: Vec, pub interaction: Option, + /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on + /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. + pub(crate) skip_auto_write: bool, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. @@ -224,6 +227,7 @@ impl Session { undo: Vec::new(), redo: Vec::new(), interaction: None, + skip_auto_write: false, clipboard: None, meta_clipboard: None, browse: None, @@ -309,8 +313,11 @@ impl Session { self.journal.drain(..1000); } } - if r.is_ok() && self.depth == 0 && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start { - self.auto_write_sidecars(&self.pending_log[log_start..]); + if self.depth == 0 { + let skip = std::mem::take(&mut self.skip_auto_write); + if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start { + self.auto_write_sidecars(&self.pending_log[log_start..]); + } } if self.depth == 0 && self.library.is_some() { // Make the command durable before reporting success. When the command's own records diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 616cfb0f8..4382160a7 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -4,7 +4,7 @@ //! ```text //! LightCraft Library/ //! catalog.snap catalog.log (lightcraft-catalog journal) -//! presets.json view.json (user presets + favourites; last source/filter/sort/selection) +//! presets.json view.json (user presets + favourites; last source/sort/selection) //! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export) //! thumbs/ (rendered thumbnail cache, safe to delete) //! Originals/ (photos imported with "copy into library") @@ -102,7 +102,8 @@ struct PresetsFile { struct ViewFile { source: LibrarySource, browse: Option, - filter: lightcraft_catalog::Filter, + // No filter: a library opens unfiltered. A date, keyword or person left over from the last session + // would silently hide photos, with only a small badge to say so. sort: lightcraft_catalog::Sort, selection: Selection, } @@ -346,7 +347,6 @@ impl Session { if let Some(v) = settings.read::(files.as_mut(), "view.json") { self.source = v.source; self.browse = v.browse; - self.filter = v.filter; self.sort = v.sort; self.selection = v.selection; self.selection.ids.retain(|id| self.catalog.photo(*id).is_some()); @@ -494,17 +494,11 @@ impl Session { } fn view_json(&self) -> Vec { - let view = ViewFile { - source: self.source, - browse: self.browse.clone(), - filter: self.filter.clone(), - sort: self.sort, - selection: self.selection.clone(), - }; + let view = ViewFile { source: self.source, browse: self.browse.clone(), sort: self.sort, selection: self.selection.clone() }; serde_json::to_vec_pretty(&view).unwrap_or_default() } - /// Save the view state (source, filter, sort, selection) if it changed since it was last + /// Save the view state (source, sort, selection) if it changed since it was last /// written. Native hosts get this from [`Session::close_library`]; the browser host calls it /// periodically, since a tab can be closed without notice. pub fn save_view(&mut self) { diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 27c9ca325..86b346e75 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8}; use serde::{Deserialize, Serialize}; /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 10; +pub const RENDER_CACHE_VERSION: u64 = 11; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; @@ -718,6 +718,48 @@ impl crate::Session { }) } + /// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the + /// photo's own look with its crop replaced by a square around the face (room for hair and chin), + /// `edge` pixels across (≤ 512). The source level is the smallest that keeps the face sharp, up + /// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content. + pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option { + let p = self.catalog.photo(id)?.clone(); + // `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the + // user's Rotate Left/Right, so carry the box (and the photo's size) over to it. + let orient = p.develop.orientation; + let face = orient.map_norm_rect(face); + let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) }; + let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7; + // finite, within the photo, and never wider than its short side (so the clamps below are valid) + let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) }; + let (cx, cy) = ((face.x0 + face.x1) / 2.0 * w, (face.y0 + face.y1) / 2.0 * h); + let (cx, cy) = (if cx.is_finite() { cx } else { w / 2.0 }, if cy.is_finite() { cy } else { h / 2.0 }); + let (x0, y0) = (cx.clamp(side / 2.0, w - side / 2.0) - side / 2.0, cy.clamp(side / 2.0, h - side / 2.0) - side / 2.0); + let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h }; + let mut settings = (*p.develop).clone(); + settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() }; + let edge = edge.clamp(16, SourceLevel::Thumb.max_edge()); + // pixels along the long edge that leave `edge` across the crop + let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64); + let level = SourceLevel::for_size(needed as usize); + let source = self.media.source_ref(&p, level); + let ck = Self::variant_key(&p, &settings, edge); + Some(RenderJob { + photo: id, + level, + source, + origin: p.source.clone(), + info: source_info(&p), + settings: Arc::new(settings), + request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) }, + key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64), + cache: Some((self.media.rendered.clone(), ck)), + stages: None, + view_cache: None, + }) + } + /// Size-independent cache key of a photo's view render (loupe) for its current settings. fn view_key(p: &Photo, apply_crop: bool) -> Hash128 { Hasher128::new() diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index 861281080..b3a140a2b 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -15,6 +15,8 @@ use lightcraft_catalog::{Catalog, Source}; pub struct OriginalGuard { /// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …) by_name: HashMap>, + /// The entries of `by_name` that are symlinks, found on the first check that needs them. + links: std::sync::OnceLock>, } impl OriginalGuard { @@ -32,6 +34,7 @@ impl OriginalGuard { /// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`). pub fn add(&mut self, path: &Path, label: &str) { let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return }; + self.links = std::sync::OnceLock::new(); let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what)); put(name.clone(), path.to_path_buf(), format!("the original of {label}")); let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); @@ -58,6 +61,19 @@ impl OriginalGuard { } } } + // An imported symlink can have a different name from its original. A write to + // that original would change what the library reads through the link. Only do + // this fallback for existing targets that the name index did not protect; the links are + // looked up once per guard, not once per target. + let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect()); + for (p, what) in links { + if same_file(p, target) { + return Err(format!( + "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", + target.display() + )); + } + } Ok(()) } } diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 69bf8f0f0..7e9249489 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -99,6 +99,7 @@ pub struct SidecarData { pub alt_text: Option, pub extended_description: Option, pub keywords: Option>, + pub regions: Option>, /// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or /// `xmp:CreateDate` (first found). Used only when the file itself has no capture time. pub captured: Option, @@ -133,6 +134,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result bool { if let Some(k) = &sc.keywords { m.keywords = k.clone(); } + if let Some(r) = &sc.regions { + m.regions = r.clone(); + } let develop = match &sc.develop { Some(DevelopPatch::Full(s)) => (**s).clone(), Some(DevelopPatch::Partial(v)) => { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 58b7e643e..2837d6726 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -727,3 +727,59 @@ fn curve_reset_by_channel_and_whole() { assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default()); assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err()); } + +/// A People card's close-up is a square (in pixels) inside the photo, around the face, whatever the +/// face rectangle says: corners, oversized and degenerate rectangles never leave the frame or panic. +#[test] +fn face_job_crops_a_square_inside_the_photo() { + use lightcraft_geom::Rect; + let mut s = demo(); + let id = s.active().unwrap(); + let (w, h) = { + let p = s.catalog.photo(id).unwrap(); + (f64::from(p.width), f64::from(p.height)) + }; + let faces = [ + Rect { x0: 0.40, y0: 0.30, x1: 0.50, y1: 0.45 }, + Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 }, + Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 }, + Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 }, + Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 }, + Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY }, + ]; + for face in faces { + let job = s.face_job(id, face, 256).expect("a job for a photo in the library"); + let r = job.settings.crop.geometry.rect; + assert!((0.0..=1.0).contains(&r.x0) && (0.0..=1.0).contains(&r.y0) && r.x1 <= 1.0 + 1e-9 && r.y1 <= 1.0 + 1e-9, "{face:?} → {r:?}"); + assert!(r.x1 > r.x0 && r.y1 > r.y0, "{face:?} → {r:?}"); + let (pw, ph) = ((r.x1 - r.x0) * w, (r.y1 - r.y0) * h); + assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in pixels: {face:?} → {pw} × {ph}"); + assert_eq!(job.settings.crop.geometry.angle, 0.0); + } + // a face around (0.45, 0.375) is centred in its crop (room to spare on every side) + let r = s.face_job(id, faces[0], 256).unwrap().settings.crop.geometry.rect; + assert!((((r.x0 + r.x1) / 2.0) - 0.45).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.375).abs() < 1e-9, "{r:?}"); + assert!(s.face_job(lightcraft_catalog::PhotoId(u64::MAX), faces[0], 256).is_none()); +} + +/// Regions are on the upright photo; after Rotate Right the close-up still frames the face: the box +/// is carried into the rotated frame the crop lives in, and the crop stays square in rotated pixels. +#[test] +fn face_job_follows_rotate_right() { + use lightcraft_geom::Rect; + let mut s = demo(); + let id = s.active().unwrap(); + let (w, h) = { + let p = s.catalog.photo(id).unwrap(); + (f64::from(p.width), f64::from(p.height)) + }; + // a small face left of centre on the upright photo + let face = Rect { x0: 0.30, y0: 0.45, x1: 0.35, y1: 0.50 }; + s.execute("photo.rotateRight", &json!({})).unwrap(); + let r = s.face_job(id, face, 256).unwrap().settings.crop.geometry.rect; + // a quarter turn clockwise puts the left of the photo at the top: centre (0.325, 0.475) → (0.525, 0.325) + assert!((((r.x0 + r.x1) / 2.0) - 0.525).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.325).abs() < 1e-9, "{r:?}"); + // the rotated photo is h × w pixels + let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w); + assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}"); +} diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 1515d7bf6..faba3e3c1 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -324,6 +324,32 @@ fn a_failed_export_write_keeps_the_previous_file() { let _ = std::fs::remove_dir_all(&dir); } +#[cfg(unix)] +#[test] +fn export_protects_the_target_of_an_imported_symlink() { + use crate::export::{Conflict, Destination}; + let (original_session, _, dir, src, original) = library_with_jpeg("export-symlink-original"); + drop(original_session); + let alias = dir.join("alias.jpg"); + std::os::unix::fs::symlink(&src, &alias).unwrap(); + let sidecar = dir.join("metadata.xmp"); + std::fs::write(&sidecar, b"").unwrap(); + std::os::unix::fs::symlink(&sidecar, alias.with_extension("xmp")).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [alias.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap(); + let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() }; + let to = Destination { dir: String::new(), exact: Some(src.to_string_lossy().to_string()) }; + let result = disk_batch(&mut s, id, &o, &to); + assert!(result.is_err(), "export replaced the original through its imported alias: {result:?}"); + assert_eq!(std::fs::read(&src).unwrap(), original); + assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err()); + assert!(s.execute("export.checkTarget", &json!({"path": sidecar.to_string_lossy()})).is_err()); + assert_eq!(std::fs::read(&sidecar).unwrap(), b""); + let _ = std::fs::remove_dir_all(&dir); +} + /// Issue #134: exports can always be made again, so they are written atomically but not synced to /// disk file by file; an edit copy that becomes a library photo still is. #[test] diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index d7436ff18..f23ae9bde 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -201,6 +201,25 @@ fn album_source_reports_in_library_state_and_persists_the_view() { let _ = std::fs::remove_dir_all(&dir); } +/// A library opens unfiltered: the source and sort come back, a filter from the last session does not +/// (it would hide photos behind a small badge). +#[test] +fn filters_are_not_restored_when_a_library_reopens() { + let dir = temp_dir("filter-not-restored"); + let mut s = open(&dir); + let all = s.visible_cloned().len(); + s.execute("library.sort", &json!({"key": "fileName"})).unwrap(); + s.execute("library.filter", &json!({"rating": 5, "date": "2026-01-16", "person": "Jane Doe"})).unwrap(); + assert!(s.visible_cloned().len() < all); + s.close_library().unwrap(); + drop(s); + let mut s2 = open(&dir); + assert_eq!(s2.filter, lightcraft_catalog::Filter::default()); + assert_eq!(s2.visible_cloned().len(), all); + assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn filter_chips_clear_one_filter_and_total_ignores_the_filter() { let dir = temp_dir("chips"); diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 0832e2fb0..fede1fec0 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -173,6 +173,162 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() { let _ = std::fs::remove_dir_all(&src); } +const MWG_REGIONS: &str = r#" + + + + + + + + + + + + + + + + + + + + +"#; + +/// A face region written by Lightroom (MWG-RS) into a sidecar reaches `Photo.meta.regions` on +/// import — the same path real catalogs use, exercised end to end rather than just at the parser. +#[test] +fn regions_from_sidecar_are_read_on_import() { + let src = temp_dir("regions"); + write_png(&src.join("portrait.png"), 9); + std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["sidecars"], 1); + let p = s.catalog.photos().next().unwrap().clone(); + assert_eq!(p.meta.regions.len(), 1); + let region = &p.meta.regions[0]; + assert_eq!(region.name.as_deref(), Some("Jane Doe")); + assert_eq!(region.kind, lightcraft_meta::RegionKind::Face); + assert!((region.rect.width() - 0.3).abs() < 1e-9, "{:?}", region.rect); + let _ = std::fs::remove_dir_all(&src); +} + +/// Removing a face box is a catalog-only, undoable edit: it never rewrites the sidecar (Lightroom's +/// regions and everything else in it stay byte for byte), even with auto-write on. +#[test] +fn removing_a_region_is_undoable_and_leaves_the_sidecar_alone() { + let src = temp_dir("region-remove"); + write_png(&src.join("portrait.png"), 9); + std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + let before = std::fs::read(src.join("portrait.xmp")).unwrap(); + let n = s.catalog.photo(id).unwrap().meta.regions.len(); + assert!(n >= 1); + assert!(s.execute("photo.removeRegion", &json!({"index": n})).is_err(), "out of range"); + assert!(s.execute("photo.removeRegion", &json!({})).is_err(), "no index"); + let r = s.execute("photo.removeRegion", &json!({"index": 0})).unwrap(); + assert_eq!(r["removed"], "Jane Doe"); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n - 1); + assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched"); + // an ordinary edit still auto-writes (the guard is per command) + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + assert_ne!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "auto-write still works for other edits"); + // undo brings the region back + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n); + let _ = std::fs::remove_dir_all(&src); +} + +/// Resizing a face box is a catalog-only, undoable edit with strict input handling; it leaves the +/// sidecar alone like removal does. +#[test] +fn resizing_a_region_validates_clamps_undoes_and_leaves_the_sidecar_alone() { + let src = temp_dir("region-resize"); + write_png(&src.join("portrait.png"), 9); + std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + let before = std::fs::read(src.join("portrait.xmp")).unwrap(); + let original = s.catalog.photo(id).unwrap().meta.regions[0].rect; + let set = |s: &mut Session, rect: serde_json::Value| s.execute("photo.setRegion", &json!({"index": 0, "rect": rect})); + // hostile input is an error and changes nothing + for bad in [ + json!({"x0": 0.1, "y0": 0.1, "x1": 0.1, "y1": 0.5}), + json!({"x0": 0.1, "y0": 0.1, "x1": 0.1001, "y1": 0.5}), + json!({"x0": 0.1, "y0": 0.1, "x1": 0.5}), + json!({"x0": "a", "y0": 0.1, "x1": 0.5, "y1": 0.5}), + json!({"x0": null, "y0": 0.1, "x1": 0.5, "y1": 0.5}), + ] { + assert!(set(&mut s, bad.clone()).is_err(), "{bad}"); + } + assert!(s.execute("photo.setRegion", &json!({"index": 9, "rect": {"x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.5}})).is_err(), "no such region"); + assert!(s.execute("photo.setRegion", &json!({"index": 0})).is_err(), "no rect"); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original); + // corners given in any order and outside the photo are clamped into it + let r = set(&mut s, json!({"x0": 0.6, "y0": 1.5, "x1": -0.2, "y1": 0.2})).unwrap(); + assert_eq!(r["rect"], json!({"x0": 0.0, "y0": 0.2, "x1": 0.6, "y1": 1.0})); + let now = s.catalog.photo(id).unwrap().meta.regions[0].rect; + assert_eq!((now.x0, now.y0, now.x1, now.y1), (0.0, 0.2, 0.6, 1.0)); + assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original); + let _ = std::fs::remove_dir_all(&src); +} + +/// Re-reading a sidecar: one that has `mwg-rs:Regions` is authoritative, so an emptied region list +/// clears the stale regions; one without `mwg-rs:Regions` (an app that doesn't do regions) leaves the +/// photo's regions alone. +#[test] +fn rereading_a_sidecar_clears_regions_only_when_it_states_them() { + let src = temp_dir("region-reread"); + write_png(&src.join("portrait.png"), 9); + std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1); + // another app rewrote the sidecar without any notion of regions: they stay + std::fs::write( + src.join("portrait.xmp"), + r#" + +"#, + ) + .unwrap(); + s.execute("photo.readMetadataFromFile", &json!({})).unwrap(); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.rating, 3, "the sidecar was read"); + assert_eq!(p.meta.regions.len(), 1, "a sidecar without mwg-rs:Regions keeps the photo's regions"); + // a region-aware app removed every region: the sidecar says so, and the catalog follows + let emptied = MWG_REGIONS.replace( + r#" + + + + "#, + "", + ); + assert_ne!(emptied, MWG_REGIONS); + std::fs::write(src.join("portrait.xmp"), emptied).unwrap(); + s.execute("photo.readMetadataFromFile", &json!({})).unwrap(); + assert!(s.catalog.photo(id).unwrap().meta.regions.is_empty(), "an emptied region list clears stale regions"); + // undo brings them back + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + fn synthetic_dng(xmp: &str) -> Vec { synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default()) } diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index b64ff04ac..757adfe94 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -163,7 +163,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { if let Some(e) = f.edited { add(if e { "Edited".into() } else { "Unedited".into() }, json!({"edited": Null})); } - for (name, key, val) in [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword)] { + for (name, key, val) in + [("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword), ("Person", "person", &f.person)] + { if let Some(x) = val { add(format!("{name}: {x}"), json!({key: Null})); } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 509285aab..327c60fcc 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -332,6 +332,23 @@ impl Orientation { // flipH ∘ rot(t) = rot(-t) ∘ flipH Orientation::from_parts(!f, (4 - t) % 4) } + /// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns + /// are their own inverses; quarter turns swap. + pub fn inverse(self) -> Orientation { + match self { + Orientation::Rotate90 => Orientation::Rotate270, + Orientation::Rotate270 => Orientation::Rotate90, + o => o, + } + } + /// Map a rectangle in normalized (0..1) coordinates of the stored image to normalized coordinates + /// of the oriented one (e.g. a face region given on the upright photo → the frame after the user's + /// Rotate Left/Right). + pub fn map_norm_rect(self, r: Rect) -> Rect { + let (ax, ay) = self.map(r.x0, r.y0, 1.0, 1.0); + let (bx, by) = self.map(r.x1, r.y1, 1.0, 1.0); + Rect::from_points(Point::new(ax, ay), Point::new(bx, by)) + } /// Map a point in stored pixel space (w×h stored dims) to oriented space. pub fn map(self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) { let (flip, t) = self.to_parts(); @@ -417,6 +434,19 @@ mod tests { assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90); } + #[test] + fn orientation_inverse_and_norm_rect() { + let r = Rect::new(0.1, 0.2, 0.3, 0.6); + for v in 1..=8 { + let o = Orientation::from_exif(v); + let back = o.inverse().map_norm_rect(o.map_norm_rect(r)); + assert!([back.x0 - r.x0, back.y0 - r.y0, back.x1 - r.x1, back.y1 - r.y1].iter().all(|d| d.abs() < 1e-12), "{o:?}: {back:?}"); + } + // a quarter turn clockwise: the left of the upright photo becomes its top + let q = Orientation::Rotate90.map_norm_rect(r); + assert!([q.x0 - 0.4, q.y0 - 0.1, q.x1 - 0.8, q.y1 - 0.3].iter().all(|d| d.abs() < 1e-12), "{q:?}"); + } + proptest::proptest! { #[test] fn affine_inverse_prop(a in -3.0f64..3.0, sx in 0.1f64..5.0, sy in 0.1f64..5.0, tx in -50.0f64..50.0, px in -20.0f64..20.0, py in -20.0f64..20.0) { diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs index 7e6c7270d..8dda6b55d 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -7,6 +7,8 @@ //! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks. //! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and //! IPTC (33723) from IFD0. +//! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the +//! XMP `uuid` box. use lightcraft_tiff::{Tiff, tags}; @@ -34,6 +36,9 @@ pub fn embedded(bytes: &[u8]) -> Embedded { _ => Embedded::default(), }; } + if let Some(c) = crate::cr3::parse_cr3(bytes) { + return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() }; + } if bytes.starts_with(&[0xff, 0xd8]) { jpeg_segments(bytes) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { diff --git a/crates/meta/src/cr3.rs b/crates/meta/src/cr3.rs new file mode 100644 index 000000000..18d33ef7f --- /dev/null +++ b/crates/meta/src/cr3.rs @@ -0,0 +1,344 @@ +//! Canon CR3: the ISO base media file container (ISO/IEC 14496-12 box structure; CR3 layout from Laurent +//! Clévy's public CR3 format notes, confirmed black-box on EOS R6 Mark III files). +//! +//! - `ftyp` with major brand `crx `. +//! - `moov` holds a Canon `uuid` box (85c0b687-820f-11e0-8111-f4ce462b6a48) whose children are `CMT1` (a TIFF +//! stream: IFD0), `CMT2` (TIFF: the Exif IFD), `CMT3` (TIFF: the Canon maker note), `CMT4` (TIFF: GPS) and the +//! `THMB` thumbnail, then one `trak` per stream: a full-size JPEG, a reduced raw, the full raw (`CRAW` sample +//! entries; raws carry a `CMP1` coding header) and timed metadata (`CTMD`). +//! - A top-level `uuid` box with the XMP uuid (be7acfcb-97a9-42e8-9c71-999491e3afac, XMP spec part 3) holds the +//! XMP packet. +//! +//! Every size and offset is checked against the buffer; nesting and box counts are bounded. + +/// A track's sample-entry kind. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Cr3TrackKind { + /// `CRAW` with a `JPEG` child: the full-size JPEG. + Jpeg, + /// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file). + Raw { width: u16, height: u16, cmp1: Option<(usize, usize)> }, + /// Anything else (`CTMD` timed metadata, unknown entries). + Other([u8; 4]), +} + +/// One track's single sample: where it is in the file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3Track { + pub kind: Cr3TrackKind, + /// Byte offset and length of the sample (the first one) in the file, when the tables give them. + pub data: Option<(usize, usize)>, +} + +/// The parts of a CR3 file LightCraft reads. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Cr3<'a> { + /// `CMT1`…`CMT4` (index 0…3). + pub cmt: [Option<&'a [u8]>; 4], + pub thumbnail: Option<&'a [u8]>, + pub xmp: Option<&'a [u8]>, + pub tracks: Vec, +} + +const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; +const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac]; +const MAX_DEPTH: usize = 12; +const MAX_BOXES: usize = 4096; +/// A `VisualSampleEntry` (78 bytes after the box header) plus 4 bytes Canon adds before the child boxes. +const CRAW_CHILDREN_AT: usize = 82; + +/// Does this look like a CR3 file (`ftyp` box with major brand `crx `)? +pub fn is_cr3(bytes: &[u8]) -> bool { + bytes.get(4..12) == Some(b"ftypcrx ".as_slice()) +} + +/// Parse a CR3 file's boxes; `None` when it is not a CR3 file. +pub fn parse_cr3(bytes: &[u8]) -> Option> { + if !is_cr3(bytes) { + return None; + } + let mut out = Cr3::default(); + let mut budget = MAX_BOXES; + for b in boxes(bytes, 0, bytes.len(), &mut budget) { + match &b.kind { + b"moov" => moov(bytes, &b, &mut out, &mut budget), + b"uuid" if b.uuid == Some(XMP_UUID) => out.xmp = bytes.get(b.body..b.end), + _ => {} + } + } + Some(out) +} + +struct BoxRef { + kind: [u8; 4], + /// Start of the payload (after the header and, for `uuid`, the 16-byte uuid). + body: usize, + end: usize, + uuid: Option<[u8; 16]>, +} + +/// The boxes directly inside `[from, to)`; stops at the first malformed header. +fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec { + let mut out = Vec::new(); + let mut at = from; + let to = to.min(bytes.len()); + while at.saturating_add(8) <= to && *budget > 0 { + *budget -= 1; + let (Some(size), Some(kind)) = (be32(bytes, at), bytes.get(at + 4..at + 8)) else { break }; + let mut kind4 = [0u8; 4]; + kind4.copy_from_slice(kind); + let (len, mut hdr) = match size { + 0 => (to - at, 8), + 1 => match be64(bytes, at + 8).and_then(|v| usize::try_from(v).ok()) { + Some(l) => (l, 16), + None => break, + }, + n => (n as usize, 8), + }; + let Some(end) = at.checked_add(len).filter(|&e| e <= to && len >= hdr) else { break }; + let mut uuid = None; + if &kind4 == b"uuid" { + let Some(u) = bytes.get(at + hdr..at + hdr + 16).filter(|_| at + hdr + 16 <= end) else { break }; + let mut a = [0u8; 16]; + a.copy_from_slice(u); + uuid = Some(a); + hdr += 16; + } + out.push(BoxRef { kind: kind4, body: at + hdr, end, uuid }); + at = end; + } + out +} + +fn moov<'a>(bytes: &'a [u8], m: &BoxRef, out: &mut Cr3<'a>, budget: &mut usize) { + for b in boxes(bytes, m.body, m.end, budget) { + match &b.kind { + b"uuid" if b.uuid == Some(CANON_UUID) => { + for c in boxes(bytes, b.body, b.end, budget) { + let slot = match &c.kind { + b"CMT1" => 0, + b"CMT2" => 1, + b"CMT3" => 2, + b"CMT4" => 3, + b"THMB" => { + out.thumbnail = bytes.get(c.body..c.end); + continue; + } + _ => continue, + }; + out.cmt[slot] = bytes.get(c.body..c.end); + } + } + b"trak" => { + if let Some(t) = trak(bytes, &b, budget) { + out.tracks.push(t); + } + } + _ => {} + } + } +} + +/// Descend `trak` → `mdia` → `minf` → `stbl` and read its sample entry and first sample's location. +fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option { + let mut stbl = None; + let mut stack = vec![(t.body, t.end, 0usize)]; + while let Some((from, to, depth)) = stack.pop() { + if depth > MAX_DEPTH { + continue; + } + for b in boxes(bytes, from, to, budget) { + match &b.kind { + b"mdia" | b"minf" => stack.push((b.body, b.end, depth + 1)), + b"stbl" => stbl = Some(b), + _ => {} + } + } + } + let stbl = stbl?; + let (mut kind, mut size, mut offset) = (None, None, None); + for b in boxes(bytes, stbl.body, stbl.end, budget) { + match &b.kind { + // full box: version/flags, entry count, entries + b"stsd" => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)), + // version/flags, sample_size (0 = per-sample table), count, sizes + b"stsz" => { + size = match be32(bytes, b.body + 4)? { + 0 if be32(bytes, b.body + 8)? > 0 => be32(bytes, b.body + 12), + 0 => None, + n => Some(n), + } + } + b"co64" if be32(bytes, b.body + 4)? > 0 => offset = be64(bytes, b.body + 8).and_then(|v| usize::try_from(v).ok()), + b"stco" if be32(bytes, b.body + 4)? > 0 => offset = be32(bytes, b.body + 8).map(|v| v as usize), + _ => {} + } + } + let data = match (offset, size) { + (Some(o), Some(s)) if o.checked_add(s as usize).is_some_and(|e| e <= bytes.len()) => Some((o, s as usize)), + _ => None, + }; + Some(Cr3Track { kind: kind?, data }) +} + +fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind { + if &e.kind != b"CRAW" { + return Cr3TrackKind::Other(e.kind); + } + let (Some(width), Some(height)) = (be16(bytes, e.body + 24), be16(bytes, e.body + 26)) else { + return Cr3TrackKind::Other(e.kind); + }; + let mut cmp1 = None; + for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) { + match &c.kind { + b"JPEG" => return Cr3TrackKind::Jpeg, + b"CMP1" => cmp1 = Some((c.body, c.end - c.body)), + _ => {} + } + } + Cr3TrackKind::Raw { width, height, cmp1 } +} + +fn be16(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]])) +} +fn be32(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])) +} +fn be64(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + let mut a = [0u8; 8]; + a.copy_from_slice(s); + Some(u64::from_be_bytes(a)) +} + +/// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3 +/// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block. +pub fn merged_exif(c: &Cr3<'_>) -> Option> { + use lightcraft_tiff::{IfdBuilder, Tiff, TiffWriter, tags as t}; + let ifd0 = Tiff::parse(c.cmt[0]?).ok()?; + let order = ifd0.order; + let first = |i: usize| c.cmt[i].and_then(|b| Tiff::parse(b).ok()).and_then(|t| t.ifds.into_iter().next()); + // pointers and offsets that would dangle once the IFDs move + const SKIP: [u16; 9] = [t::EXIF_IFD, t::GPS_IFD, t::INTEROP_IFD, t::SUB_IFDS, t::MAKER_NOTE, 273, 279, 513, 514]; + let build = |ifd: &lightcraft_tiff::Ifd| { + let mut b = IfdBuilder::new(); + for e in ifd.entries.iter().filter(|e| !SKIP.contains(&e.tag)) { + b.set(e.tag, e.value.clone()); + } + b + }; + let mut root = build(ifd0.ifds.first()?); + if let Some(exif) = first(1) { + root.set_child(t::EXIF_IFD, build(&exif)); + } + if let Some(gps) = first(3) { + root.set_child(t::GPS_IFD, build(&gps)); + } + TiffWriter::new(order, false).write(&[root]).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bx(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(body); + v + } + fn uuid(u: &[u8; 16], body: &[u8]) -> Vec { + let mut b = u.to_vec(); + b.extend_from_slice(body); + bx(b"uuid", &b) + } + fn full(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut b = vec![0u8; 4]; + b.extend_from_slice(body); + bx(kind, &b) + } + fn craw(w: u16, h: u16, children: &[u8]) -> Vec { + let mut b = vec![0u8; CRAW_CHILDREN_AT]; + b[24..26].copy_from_slice(&w.to_be_bytes()); + b[26..28].copy_from_slice(&h.to_be_bytes()); + b.extend_from_slice(children); + bx(b"CRAW", &b) + } + fn trak(entry: Vec, offset: u64, size: u32) -> Vec { + let mut stsd = 1u32.to_be_bytes().to_vec(); + stsd.extend(entry); + let mut stsz = 0u32.to_be_bytes().to_vec(); + stsz.extend(1u32.to_be_bytes()); + stsz.extend(size.to_be_bytes()); + let mut co64 = 1u32.to_be_bytes().to_vec(); + co64.extend(offset.to_be_bytes()); + let stbl = [full(b"stsd", &stsd), full(b"stsz", &stsz), full(b"co64", &co64)].concat(); + bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))) + } + fn tiff_with(tag: u16, v: lightcraft_tiff::Value) -> Vec { + let b = lightcraft_tiff::IfdBuilder::new().with(tag, v); + lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[b]).unwrap() + } + + /// A small synthetic CR3: Canon uuid with CMT1/CMT2/CMT4, a JPEG track, a raw track, XMP. + pub(crate) fn sample() -> Vec { + use lightcraft_tiff::{Value, tags as t}; + let cmt1 = tiff_with(t::MODEL, Value::Ascii("Canon EOS Test".into())); + let cmt2 = tiff_with(t::ISO_SPEED, Value::Short(vec![400])); + let cmt4 = tiff_with(1, Value::Ascii("N".into())); + let canon = uuid(&CANON_UUID, &[bx(b"CMT1", &cmt1), bx(b"CMT2", &cmt2), bx(b"CMT4", &cmt4), bx(b"THMB", b"thumb")].concat()); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let payload_at = 4096u64; + let jpeg_t = trak(craw(64, 48, &bx(b"JPEG", &[0; 4])), payload_at, 16); + let raw_t = trak(craw(60, 40, &bx(b"CMP1", &[0xff, 0x10, 0, 0x30])), payload_at + 16, 32); + file.extend(bx(b"moov", &[canon, jpeg_t, raw_t].concat())); + file.extend(uuid(&XMP_UUID, b"")); + file.resize(payload_at as usize + 64, 0); + file + } + + #[test] + fn parses_canon_boxes_tracks_and_xmp() { + let f = sample(); + assert!(is_cr3(&f)); + let c = parse_cr3(&f).unwrap(); + assert!(c.cmt[0].is_some() && c.cmt[1].is_some() && c.cmt[2].is_none() && c.cmt[3].is_some()); + assert_eq!(c.thumbnail, Some(b"thumb".as_slice())); + assert_eq!(c.xmp, Some(b"".as_slice())); + assert_eq!(c.tracks.len(), 2); + assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) }); + let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)) } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) }; + assert_eq!((width, height, len), (60, 40, 4)); + assert_eq!(&f[at..at + 2], &[0xff, 0x10]); + assert_eq!(c.tracks[1].data, Some((4112, 32))); + } + + #[test] + fn merged_exif_reads_like_a_tiff_raw() { + let f = sample(); + let m = crate::read_exif(&merged_exif(&parse_cr3(&f).unwrap()).unwrap()); + assert_eq!(m.model.as_deref(), Some("Canon EOS Test")); + assert_eq!(m.iso, Some(400)); + } + + #[test] + fn hostile_input_never_panics() { + let f = sample(); + // every truncation and single-byte corruption parses to something or nothing + for n in 0..f.len().min(800) { + let _ = parse_cr3(&f[..n]).map(|c| merged_exif(&c)); + } + for i in 0..f.len().min(800) { + for v in [0u8, 1, 0x7f, 0xff] { + let mut g = f.clone(); + g[i] = v; + let _ = parse_cr3(&g).map(|c| merged_exif(&c)); + } + } + // a sample offset past the end is not reported + let mut g = f.clone(); + g.truncate(4100); + assert!(parse_cr3(&g).unwrap().tracks.iter().all(|t| t.data.is_none())); + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 3408aed76..31a35cc65 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -7,10 +7,12 @@ //! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON). //! - [`parse_iptc`] — IPTC-IIM record 2 datasets. //! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time. -//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw). +//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3). +//! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks). #![forbid(unsafe_code)] mod container; +pub mod cr3; mod datetime; mod exif; mod gpx; @@ -24,7 +26,7 @@ pub use datetime::DateTime; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif}; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; -pub use lightcraft_geom::Orientation; +pub use lightcraft_geom::{Orientation, Rect}; pub use tags::{TagRow, file_tag_rows, tag_rows}; pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, XmpValue, parse_xmp, write_xmp, write_xmp_lc}; pub use xmp_merge::{MergeRules, merge_xmp}; @@ -48,6 +50,31 @@ pub struct Flash { pub raw: u16, } +/// A named region of interest on a photo (MWG Region Guidelines, `mwg-rs:Regions`): most often a face, +/// drawn by Lightroom, digiKam, Picasa or similar tools. Read-only for now — LightCraft does not write +/// regions yet (see `docs/xmp-interop.md`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Region { + /// Normalized to the image's full, oriented frame (`Metadata::width`/`height`), y-down, 0..1 on + /// both axes — MWG's own convention, and what `AppliedToDimensions` areas are converted to. + pub rect: Rect, + pub kind: RegionKind, + /// `mwg-rs:Name` (e.g. the person's name). + pub name: Option, + pub description: Option, +} + +/// `mwg-rs:Type`. Unrecognised values are kept verbatim rather than dropped. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RegionKind { + Face, + Pet, + Focus, + BarCode, + /// `mwg-rs:Type` was missing, empty, or a value we don't recognise. + Other(String), +} + /// Everything LightCraft shows or searches about a photo. All fields are optional; unknown = `None`/empty. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Metadata { @@ -113,6 +140,8 @@ pub struct Metadata { pub rating: Option, /// Colour label name (e.g. "Red"). pub label: Option, + /// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only. + pub regions: Vec, } macro_rules! fill { @@ -173,6 +202,9 @@ impl Metadata { if self.hierarchical_keywords.is_empty() { self.hierarchical_keywords = other.hierarchical_keywords.clone(); } + if self.regions.is_empty() { + self.regions = other.regions.clone(); + } } /// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with @@ -185,6 +217,9 @@ impl Metadata { if !other.hierarchical_keywords.is_empty() { self.hierarchical_keywords = other.hierarchical_keywords.clone(); } + if !other.regions.is_empty() { + self.regions = other.regions.clone(); + } } /// Exposure time formatted like cameras show it: `1/250`, `0.5`, `2″`. diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 07373e62b..b8a541096 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -8,7 +8,8 @@ //! Writing emits the interchange subset in standard namespaces (dc, xmp, photoshop, exif, exifEX, tiff, lr) and //! LightCraft's full develop settings as an opaque JSON string in `lc:settings`. -use crate::{DateTime, Flash, Gps, Metadata, Orientation, parse_number}; +use crate::{DateTime, Flash, Gps, Metadata, Orientation, Region, RegionKind, parse_number}; +use lightcraft_geom::{Point, Rect}; use quick_xml::escape::{escape, partial_escape}; use quick_xml::events::Event; use std::collections::BTreeMap; @@ -32,6 +33,11 @@ const NAMESPACES: &[(&str, &str)] = &[ ("lc", LC_NS), // Read-only: develop settings written by other raw developers (interchange; see docs/xmp-interop.md). ("crs", CRS_NS), + // Read-only: face/pet/focus regions written by Lightroom, digiKam, Picasa and others (MWG Region + // Guidelines v2.0). Canonicalising these three lets a packet use any prefix for them. + ("mwg-rs", "http://www.metadataworkinggroup.com/schemas/regions/"), + ("stArea", "http://ns.adobe.com/xmp/sType/Area#"), + ("stDim", "http://ns.adobe.com/xap/1.0/sType/Dimensions#"), ]; /// The camera-raw-settings namespace URI (prefix `crs`), read for interchange only. @@ -374,6 +380,72 @@ fn parse_gps_coord(s: &str) -> Option { v.is_finite().then_some(v) } +/// `mwg-rs:Regions` → every region in its `RegionList`, converted to the full-image normalized frame. +/// Malformed or out-of-range entries are skipped rather than rejecting the whole list: one bad region +/// (from Lightroom, digiKam, Picasa, or a hand-edited file) shouldn't hide the rest. +fn parse_regions(regions: &XmpValue) -> Vec { + // `AppliedToDimensions` documents the pixel size regions were authored against. It's only consulted + // for the rare region whose own Area is in pixel units instead of MWG's normalized default below. + let px_dims = regions.field("mwg-rs:AppliedToDimensions").and_then(|d| { + let w = d.field("stDim:w").and_then(XmpValue::text).and_then(parse_number)?; + let h = d.field("stDim:h").and_then(XmpValue::text).and_then(parse_number)?; + (w > 0.0 && h > 0.0).then_some((w, h)) + }); + let Some(list) = regions.field("mwg-rs:RegionList") else { return Vec::new() }; + list.items().iter().filter_map(|item| parse_one_region(item, px_dims)).collect() +} + +fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option { + let area = item.field("mwg-rs:Area")?; + let num_attr = |k: &str| area.field(k).and_then(XmpValue::text).and_then(parse_number); + // stArea:x/y are the region's CENTER, not its top-left corner (MWG Region Guidelines §Area). + let (cx, cy, w, h) = (num_attr("stArea:x")?, num_attr("stArea:y")?, num_attr("stArea:w")?, num_attr("stArea:h")?); + let finite_and_sane = [cx, cy, w, h].iter().all(|v| v.is_finite() && v.abs() <= 1.0e6); + if !finite_and_sane || w <= 0.0 || h <= 0.0 { + return None; + } + // `stArea:unit` is "normalized" (a fraction of AppliedToDimensions) in every writer we know of, and + // that needs no cross-referencing at all. Only a region that explicitly says "pixel" gets divided + // down, and only if AppliedToDimensions is there to divide by; otherwise it's dropped rather than + // stored as a nonsense fraction. + let is_pixels = area.field("stArea:unit").and_then(XmpValue::text).is_some_and(|u| u.eq_ignore_ascii_case("pixel")); + let (cx, cy, w, h) = match (is_pixels, px_dims) { + (false, _) => (cx, cy, w, h), + (true, Some((dw, dh))) => (cx / dw, cy / dh, w / dw, h / dh), + (true, None) => return None, + }; + // Lightroom Classic stores the box of a photo with Exif orientation 3 / 6 / 8 in the sensor's own + // frame and writes the orientation as the region's `mwg-rs:Rotation` (π for 3, −π/2 for 6, +π/2 for + // 8; 0 for 1) while `AppliedToDimensions` names the upright size. Turn it into the upright frame. + // MWG has no way to say "mirrored", so orientations 2/4/5/7 can't be told apart from their unmirrored + // twins here; boxes on mirrored photos are taken as written (see LR-LIB-PEOPLE in docs/parity.md). + let rotation = item.field("mwg-rs:Rotation").and_then(XmpValue::text).and_then(parse_number).filter(|r| r.is_finite()); + let (quarter, half) = (std::f64::consts::FRAC_PI_2, std::f64::consts::PI); + let (cx, cy, w, h) = match rotation { + Some(r) if (r + quarter).abs() < 0.05 => (1.0 - cy, cx, h, w), + Some(r) if (r - quarter).abs() < 0.05 => (cy, 1.0 - cx, h, w), + Some(r) if (r.abs() - half).abs() < 0.05 => (1.0 - cx, 1.0 - cy, w, h), + _ => (cx, cy, w, h), + }; + // Keep only the part of the box inside the photo: a region hanging off an edge (common for faces + // cut by the frame) is clipped, and one entirely outside it (hostile or stale) is dropped, so every + // stored rect is within 0..1 and non-empty. + let rect = Rect::from_center(Point::new(cx, cy), w, h).intersect(&Rect::UNIT); + if rect.is_empty() { + return None; + } + let name = item.field("mwg-rs:Name").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty()); + let description = item.field("mwg-rs:Description").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty()); + let kind = match item.field("mwg-rs:Type").and_then(XmpValue::text).unwrap_or("") { + t if t.eq_ignore_ascii_case("face") => RegionKind::Face, + t if t.eq_ignore_ascii_case("pet") => RegionKind::Pet, + t if t.eq_ignore_ascii_case("focus") => RegionKind::Focus, + t if t.eq_ignore_ascii_case("barcode") => RegionKind::BarCode, + t => RegionKind::Other(t.to_string()), + }; + Some(Region { rect, kind, name, description }) +} + fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String { let a = v.abs(); let d = a.trunc(); @@ -470,6 +542,7 @@ pub fn parse_xmp(s: &str) -> Result { let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a }); m.gps = Some(Gps { latitude: lat, longitude: lon, altitude }); } + m.regions = values.get("mwg-rs:Regions").map(parse_regions).unwrap_or_default(); let lc_settings = props.get("lc:settings").and_then(|v| v.first()).cloned(); Ok(XmpData { metadata: m, lc_settings, properties: props, values }) } @@ -610,6 +683,9 @@ mod tests { fn full() -> Metadata { Metadata { + // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the + // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. + regions: Vec::new(), make: Some("Maker & Sons".into()), model: Some("X <1>".into()), serial_number: Some("SN1".into()), @@ -764,4 +840,204 @@ mod tests { assert_eq!(parse_gps_coord("é"), None); assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); } + + /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how + /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one + /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas + /// (the default, and the only unit real files use) don't need it. + #[test] + fn mwg_regions_element_form() { + let x = r#" + + + + + + + + Jane Doe + Face + + + + + + + Pet + Rex + Good boy + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 3, "{regions:?}"); + + assert_eq!(regions[0].kind, RegionKind::Face); + assert_eq!(regions[0].name.as_deref(), Some("Jane Doe")); + assert_eq!(regions[0].description, None); + let r = regions[0].rect; + assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}"); + assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}"); + + // No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`. + assert_eq!(regions[1].kind, RegionKind::Other(String::new())); + assert_eq!(regions[1].name, None); + + assert_eq!(regions[2].kind, RegionKind::Pet); + assert_eq!(regions[2].name.as_deref(), Some("Rex")); + assert_eq!(regions[2].description.as_deref(), Some("Good boy")); + } + + /// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame + /// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the + /// upright frame. + #[test] + fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() { + let x = r#" + + + + + + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 5, "{regions:?}"); + let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9); + assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect); + assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect); + assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect); + assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect); + assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect); + } + + /// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an + /// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an + /// unrecognised `mwg-rs:Type` value kept verbatim. + #[test] + fn mwg_regions_attribute_form_and_unknown_type() { + let x = r#" + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 2, "{regions:?}"); + assert_eq!(regions[0].kind, RegionKind::BarCode); + assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string())); + } + + /// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of + /// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area + /// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction). + #[test] + fn mwg_regions_hostile_inputs_are_dropped_not_panicking() { + let x = r#" + + + + + No Area + + + + + + Face + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}"); + assert_eq!(regions[0].kind, RegionKind::Face); + } + + /// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a + /// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view + /// would then draw or crop far outside the image. + #[test] + fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() { + let x = r#" + + + + + Far right + Far left + Touching edge + Huge + Corner + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect(); + assert_eq!(names, ["Huge", "Corner"], "{regions:?}"); + for r in ®ions { + let q = r.rect; + assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}"); + } + assert_eq!(regions[0].rect, Rect::UNIT); + let c = regions[1].rect; + assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}"); + } + + /// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). + #[test] + fn regions_merge_like_keywords() { + let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None }; + let mut a = Metadata::default(); + let b = Metadata { regions: vec![with_region.clone()], ..Default::default() }; + a.fill_missing(&b); + assert_eq!(a.regions, vec![with_region.clone()]); + + let mut a = Metadata::default(); + a.overlay_user_fields(&b); + assert_eq!(a.regions, vec![with_region]); + } } diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 050870ce1..ded35d294 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -96,10 +96,20 @@ pub fn embedded_preview(bytes: &[u8]) -> Option> { found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec()) } +/// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes. +fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> { + let full = lightcraft_meta::cr3::parse_cr3(bytes) + .and_then(|c| c.tracks.iter().find(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Jpeg).and_then(|t| t.data)); + if let Some(j) = full.and_then(|(at, len)| bytes.get(at..at.checked_add(len)?)).filter(|j| is_dct_jpeg(j)) { + return Some(j); + } + cr3_preview_boxes(bytes) +} + /// Canon CR3 (ISO base media file): the `PRVW` box (Laurent Clévy's CR3 notes; layout confirmed on a CC0 /// sample) is `u32 size, "PRVW", u32 0, u16 ?, u16 width, u16 height, u16 ?, u32 jpeg length, JPEG`; the smaller /// `THMB` box has the same shape. Returns the larger valid one. -fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> { +fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> { let mut best: Option<&[u8]> = None; for tag in [b"PRVW", b"THMB"] { let mut from = 0; @@ -180,4 +190,32 @@ mod tests { let _ = embedded_preview(&cr3[..n]); } } + + /// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box. + #[test] + fn cr3_prefers_the_full_size_jpeg_track() { + let bx = |kind: &[u8; 4], body: &[u8]| -> Vec { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() }; + let full = |kind: &[u8; 4], body: &[u8]| bx(kind, &[&[0u8; 4][..], body].concat()); + let (small, big) = (fake_jpeg(300), fake_jpeg(3000)); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let mut prvw = b"\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01".to_vec(); + prvw.extend_from_slice(&(small.len() as u32).to_be_bytes()); + prvw.extend_from_slice(&small); + file.extend(bx(b"PRVW", &prvw)); + // CRAW sample entry: 82 bytes, then a JPEG child box; one sample at `at` + let mut craw = vec![0u8; 82]; + craw.extend(bx(b"JPEG", &[0; 4])); + let at = 2048u64; + let stsd = full(b"stsd", &[&1u32.to_be_bytes()[..], &bx(b"CRAW", &craw)].concat()); + let stsz = full(b"stsz", &[0u32.to_be_bytes(), 1u32.to_be_bytes(), (big.len() as u32).to_be_bytes()].concat()); + let co64 = full(b"co64", &[&1u32.to_be_bytes()[..], &at.to_be_bytes()].concat()); + let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &[stsd, stsz, co64].concat())))); + file.extend(bx(b"moov", &trak)); + file.resize(at as usize, 0); + file.extend_from_slice(&big); + assert_eq!(embedded_preview(&file).unwrap(), big); + // a track pointing past the end falls back to PRVW + file.truncate(at as usize + 100); + assert_eq!(embedded_preview(&file).unwrap(), small); + } } diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index e81ada365..bf386bb4b 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -56,10 +56,17 @@ pub(crate) fn decode(bytes: &[u8]) -> Result { if info.compression == t::compression::NIKON { compressed(bytes, &info, mn.as_ref())? } else { uncompressed(bytes, &info, tiff.order)? }; let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float NEF".into())) }; + // Maker note 0x003d is in 14-bit units whatever the sample depth: 12-bit files from the D750, D780, D850, + // D7500 and Z 50 store 600 / 1008 / 400 / 400 / 1008 while their darkest samples are 150 / ~252 / 99 / 99 / ~250 + // (our measurements on CC0 raw.pixls.us samples; the same bodies' 14-bit files match the tag as is). + let black_scale = if bits == 12 { 0.25 } else { 1.0 }; let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_LEVEL)).as_deref() { - Some([a, b, c, d]) if [a, b, c, d].iter().all(|v| **v < 16384.0) => { - BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() } - } + Some([a, b, c, d]) if [a, b, c, d].iter().all(|v| **v < 16384.0) => BlackLevel { + repeat_rows: 2, + repeat_cols: 2, + values: [a, b, c, d].iter().map(|v| (**v * black_scale) as f32).collect(), + ..Default::default() + }, _ => BlackLevel::uniform(0.0), }; let wb = mn @@ -145,6 +152,11 @@ mod tests { use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; fn nef(compression: u16, bits: u16, strips: Vec>, w: u32, h: u32, rps: u32) -> Vec { + nef_with_note(compression, bits, strips, w, h, rps, None) + } + + /// [`nef`] with an optional maker note (`Nikon\0` v2 header + embedded big-endian TIFF holding `note`). + fn nef_with_note(compression: u16, bits: u16, strips: Vec>, w: u32, h: u32, rps: u32, note: Option) -> Vec { let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); @@ -158,10 +170,31 @@ mod tests { let mut ifd0 = IfdBuilder::new(); ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into())); ifd0.set(t::MODEL, Value::Ascii("NIKON TEST".into())); + if let Some(mn) = note { + let mut bytes = b"Nikon\0\x02\x10\0\0".to_vec(); + bytes.extend(TiffWriter::new(ByteOrder::Big, false).write(&[mn]).unwrap()); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(bytes)); + ifd0.set_child(t::EXIF_IFD, exif); + } ifd0.add_sub_ifd(raw); TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() } + #[test] + fn black_level_tag_is_in_14_bit_units() { + let (w, h) = (16usize, 4usize); + let words: Vec = (0..w * h).flat_map(|i| (100 + i as u16).to_be_bytes()).collect(); + let note = || { + let mut mn = IfdBuilder::new(); + mn.set(BLACK_LEVEL, Value::Short(vec![400, 404, 408, 412])); + mn + }; + let black = |bits| crate::decode(&nef_with_note(1, bits, vec![words.clone()], w as u32, h as u32, h as u32, Some(note()))).unwrap().black; + assert_eq!(black(12).values, vec![100.0, 101.0, 102.0, 103.0]); + assert_eq!(black(14).values, vec![400.0, 404.0, 408.0, 412.0]); + } + #[test] fn uncompressed_word16_and_packed12() { let (w, h) = (16usize, 6usize); diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index b92d03f89..ad1870323 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -194,6 +194,35 @@ fn corpus_nef_compressed_matches_uncompressed() { } } +/// raw.pixls.us has the same D7500 scene as 12- and 14-bit lossless compressed NEF. Both store black level 400 in +/// maker note 0x003d (14-bit units): after subtracting the black level and scaling to white, the two must agree. +#[test] +fn corpus_nef_12_bit_black_level_matches_14_bit() { + let dir = corpus_root().join("raw"); + let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d7500-lossless12.nef")), std::fs::read(dir.join("nef-nikon-d7500-lossless14.nef"))) + else { + eprintln!("skip: D7500 NEF pair absent"); + return; + }; + let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); + assert_eq!((a.bits, b.bits), (12, 14)); + assert!((a.black.values[0] - 100.0).abs() < 1.0 && (b.black.values[0] - 400.0).abs() < 1.0, "{:?} {:?}", a.black, b.black); + let normalized = |img: &lightcraft_raw::RawImage| { + let (black, white) = (img.black.values[0] as f64, img.white[0] as f64); + block_means(img).into_iter().map(|v| (v - black) / (white - black)).collect::>() + }; + // the two shots aren't pixel-aligned (handheld): compare the distributions, not block by block + let (mut na, mut nb) = (normalized(&a), normalized(&b)); + na.sort_by(f64::total_cmp); + nb.sort_by(f64::total_cmp); + for q in [0.1, 0.5, 0.9] { + let (x, y) = (na[(na.len() as f64 * q) as usize], nb[(nb.len() as f64 * q) as usize]); + eprintln!("D7500 12- vs 14-bit: normalized quantile {q}: {x:.4} vs {y:.4}"); + // with the tag read as 12-bit units, the 12-bit values would sit below black (negative) + assert!(x > 0.0 && (0.8..1.25).contains(&(x / y)), "quantile {q}: {x} vs {y}"); + } +} + /// Issue #138: DNGs converted by Adobe software carry their camera profile's hue/saturation map and /// look table; we read them (and render with them). Camera-written DNGs here carry none. #[test] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b2047b0d9..1dd1a0631 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lightcraft-geom = { workspace = true } +lightcraft-meta = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } lightcraft-develop = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 75c3e03c9..ebdfa0478 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,18 +1,100 @@ -//! Compile translated format strings so Rust checks both languages' placeholders. -use std::{collections::BTreeMap, env, fs, path::PathBuf}; +//! Compile the translated format strings into the `tr_format!` macro. +//! +//! `locales/-formats.json` maps an English format string to its translation for one language. +//! Every English message becomes one `tr_format!` arm that picks the active language and formats +//! that language's translation with `format!` — so Rust checks every catalog's placeholders against +//! the call site's arguments (a wrong or forgotten placeholder is a compile error, and `{:.1}`, +//! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means +//! adding its file (and its `language_table!` entry); this script picks the file up by name. +//! +//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog +//! does not compile, so a message missing from one language would otherwise go unnoticed until it +//! shows English. +use std::{ + collections::{BTreeMap, BTreeSet}, + env, fs, + path::PathBuf, +}; fn main() -> Result<(), Box> { - println!("cargo:rerun-if-changed=locales/ja-formats.json"); - let messages: BTreeMap = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?; - let mut source = String::from("macro_rules! tr_format {\n"); - for (english, japanese) in messages { - let en = serde_json::to_string(&english)?; - let ja = serde_json::to_string(&japanese)?; - source.push_str(&format!( - "({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n" - )); - } - source.push_str("}\npub(crate) use tr_format;\n"); - fs::write(PathBuf::from(env::var("OUT_DIR")?).join("ja-formats.rs"), source)?; + println!("cargo:rerun-if-changed=build.rs"); + // The directory itself, so a catalog added or removed reruns this script. + println!("cargo:rerun-if-changed=locales"); + let out = PathBuf::from(env::var("OUT_DIR")?); + fs::write(out.join("tr-formats.rs"), generate()?)?; Ok(()) } + +/// `ja` → `Ja`, `zh-hans` → `ZhHans`: the `Locale` variant the `language_table!` entry declares. +/// The code must be a lowercase BCP-47-like tag (`^[a-z]+(-[a-z]+)*$`), so the variant is always a +/// valid Rust identifier. +fn variant_for(code: &str) -> Result { + let valid = !code.is_empty() && code.split('-').all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_lowercase())); + if !valid { + return Err(format!("locales/{code}-formats.json: the language code must match ^[a-z]+(-[a-z]+)*$")); + } + Ok(code.split('-').map(capitalize).collect()) +} + +fn capitalize(part: &str) -> String { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +fn generate() -> Result> { + let mut catalogs: Vec<(String, String, BTreeMap)> = Vec::new(); + for entry in fs::read_dir("locales")? { + let path = entry?.path(); + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_string(); + // `en` is the source language: it has no catalog, its formats are the source themselves. + let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue }; + println!("cargo:rerun-if-changed=locales/{name}"); + let variant = variant_for(code)?; + let messages: BTreeMap = + serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?; + catalogs.push((code.to_string(), variant, messages)); + } + catalogs.sort_by(|a, b| a.0.cmp(&b.0)); + + // Every catalog translates the same messages. + let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect(); + let mut problems = Vec::new(); + for (code, _, messages) in &catalogs { + for key in &all { + if !messages.contains_key(*key) { + problems.push(format!("locales/{code}-formats.json is missing {key:?}")); + } + } + } + if !problems.is_empty() { + return Err(problems.join("\n").into()); + } + + let mut source = String::from(concat!( + "/// Translate a format string, checked by `format!` in every language.\n", + "/// `build.rs` writes one arm per message from `locales/*-formats.json`.\n", + "macro_rules! tr_format {\n", + )); + for english in all { + let en = serde_json::to_string(english)?; + source.push_str(&format!(" ({en} $(, $($args:tt)*)?) => {{\n match $crate::i18n::language() {{\n")); + for (_, variant, messages) in &catalogs { + if let Some(translated) = messages.get(english) { + let translated = serde_json::to_string(translated)?; + source.push_str(&format!(" $crate::i18n::Locale::{variant} => format!({translated} $(, $($args)*)?),\n")); + } + } + source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n")); + } + source.push_str(concat!( + "}\n", + "\n", + "// `macro_rules!` is textual: the module re-export is what lets call sites say\n", + "// `crate::i18n::tr_format!`.\n", + "pub(crate) use tr_format;\n", + )); + Ok(source) +} diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 9da942f87..1944d7b6a 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -100,5 +100,23 @@ "… and {more} more": "…ほか{more}件", "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}", "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚", - "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚" + "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}・右クリック ▸ 復元、または完全に削除してから読み込み直し", + "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 4e5c71a5f..f131d0b46 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1017,5 +1017,15 @@ "Quit": "終了", "Temporary session — your library isn't open, and nothing you do here is saved.": "一時セッション:ライブラリが開かれていないため、ここでの操作は保存されません。", "Back Up Library…": "ライブラリをバックアップ…", - "Restore Library from Backup…": "バックアップからライブラリを復元…" + "Restore Library from Backup…": "バックアップからライブラリを復元…", + "In Recently Deleted": "最近削除した写真内", + "Reload from Disk": "ディスクから再読み込み", + "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 new file mode 100644 index 000000000..c3713eb18 --- /dev/null +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -0,0 +1,122 @@ +{ + " · {skipped} skipped (file exists)": " · 已跳过 {skipped} 个(文件已存在)", + " · {} duplicate{} skipped": " · 已跳过 {0} 个重复项{1:.0}", + " · {} failed: {}": " · {0} 个失败:{1}", + " · {} moved": " · 已移动 {0} 个", + " · {} not readable": " · {0} 个无法读取", + ", {failed} file{} not readable": ",{failed} 个文件无法读取{0:.0}", + "Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片", + "Add {} Photo{}": "添加 {0} 张照片{1:.0}", + "Add “{}” to Favorites": "将“{0}”添加到收藏夹", + "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}", + "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}", + "All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。", + "Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片", + "Automatic ({} MB)": "自动({0} MB)", + "Click {}:1": "单击 {0}:1", + "Create {n} Virtual Copies": "创建 {n} 个虚拟副本", + "Decoder: {reason}": "解码器:{reason}", + "Delete \"{label}\"": "删除 \"{label}\"", + "Delete \"{label}\" (and the mask)": "删除 \"{label}\"(及蒙版)", + "Delete {n} Photos": "删除 {n} 张照片", + "Delete “{current}”": "删除“{current}”", + "Duplicate \"{label}\"": "复制 \"{label}\"", + "Example: {example}": "示例:{example}", + "Exported {n} photo{}": "已导出 {n} 张照片{0:.0}", + "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片{0:.0}", + "Exported {} point curve preset{}": "已导出 {0} 个点曲线预设{1:.0}", + "Exported {} preset{}": "已导出 {0} 个预设{1:.0}", + "Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张", + "Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选{0:.0}", + "Healed {n} dust spot{}": "已修复 {n} 处尘点{0:.0}", + "Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设{0:.0}", + "Imported {n} preset{}": "已导入 {n} 个预设{0:.0}", + "Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取{0:.0}{1:.0}", + "Imported {} photo{}": "已导入 {0} 张照片{1:.0}", + "Matches: {}": "匹配项:{0}", + "Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%", + "Move {what} to Recently Deleted?": "将 {what} 移到最近删除?", + "Named ({named_n})": "已命名({named_n})", + "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}", + "Preset: {name}": "预设:{name}", + "Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({0})", + "Preview only — {}": "仅预览 — {0}", + "Profile: {}": "配置文件:{0}", + "Proof Copy ({})": "校样副本({0})", + "Proof Preview · {}": "校样预览 · {0}", + "Quick Develop · {n} photos": "快速编辑 · {n} 张照片", + "Rated {}": "已评级 {0}", + "Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张", + "Redo {}": "重做:{0}", + "Remove “{}” from Favorites": "从收藏夹中移除“{0}”", + "Rename {n} Photos…": "重命名 {n} 张照片…", + "Rename “{name}”": "重命名“{name}”", + "Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}", + "Reset {} Channel": "重置 {0} 通道", + "Saved export preset “{}”": "已保存导出预设“{0}”", + "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}", + "Showing {n} of {}": "显示 {0} 张中的 {n} 张", + "Smart album: {}": "智能相册:{0}", + "Undo {}": "撤销 {0}", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {0} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。", + "Version: {}": "版本:{0}", + "none (CPU rendering): {why}": "无(CPU 渲染):{why}", + "the {n} selected photos": "选中的 {n} 张照片", + "the {} photos in view": "视图中的 {0} 张照片", + "these {count} photos": "这 {count} 张照片", + "{count} smart preview{} · {:.1} MB": "{count} 个智能预览{0:.0} · {1:.1} MB", + "{f} folders (and their subfolders)": "{f} 个文件夹(及其子文件夹)", + "{label} Grading": "{label} 颜色分级", + "{label} {d:+} on every selected photo": "每张选中照片的 {label} {d:+}", + "{label}, {n} photos": "{label},{n} 张照片", + "{matching} of {t} photos": "{t} 张照片中的 {matching} 张", + "{matching} photos": "{matching} 张照片", + "{mode} spot {} of {}": "{mode}:第 {0} 个污点,共 {1} 个", + "{msg} ({failed} failed)": "{msg}({failed} 项失败)", + "{name} Label": "{name} 标签", + "{name} Off": "已关闭 {name}", + "{name} On": "已开启 {name}", + "{name} — double-click to reset it": "{name} — 双击可重置", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 项更改已保存在内存中,但尚未写入磁盘:{e}\nLightCraft 会自动重试;现在退出将丢失这些更改。{0:.0}{1:.0}", + "{n} found · {sel} selected": "找到 {n} 张 · 已选 {sel} 张", + "{n} photos": "{n} 张照片", + "{n} photos in {} are hidden by the filters above": "{0} 中的 {n} 张照片被上方的过滤器隐藏", + "{n} photo{}": "{n} 张照片{0:.0}", + "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新{0:.0}", + "{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新{0:.0}", + "{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… {0:.0}%", + "{what} merge added": "已添加 {what} 合并", + "{what} merge failed: {e}": "{what} 合并失败:{e}", + "{} and higher": "{0} 及以上", + "{} files": "{0} 个文件", + "{} files and {f} folder{}": "{0} 个文件和 {f} 个文件夹{1:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {0} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。", + "{} photos": "{0} 张照片", + "{} photo{} not readable": "{0} 张照片无法读取{1:.0}", + "{} spot(s) on this photo": "此照片上有 {0} 处污点", + "· {dups} already in the library": "· {dups} 张已在图库中", + "· {n} photo{}": "· {n} 张照片{0:.0}", + "… and {more} more": "…以及另外 {more} 项", + "Import cancelled · {} photo{} added": "导入已取消 · 已添加 {0} 张照片{1:.0}", + "Reading photos… {} of {}": "正在读取照片… 第 {0} 张,共 {1} 张", + "Adding photos… {} of {}": "正在添加照片… 第 {0} 张,共 {1} 张", + " ({} in Recently Deleted)": "(其中 {0} 张在“最近删除”中)", + "Already in the library: {} photo{} selected in All Photos": "已在图库中:已在“所有照片”中选择 {0} 张照片{1:.0}", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入", + "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 new file mode 100644 index 000000000..7dc4d7c3b --- /dev/null +++ b/crates/ui-egui/locales/zh-hans.json @@ -0,0 +1,1031 @@ +{ + "+ Group": "+ 组", + "+ Rule": "+ 条件", + "0: nothing is rejected.": "0:不排除任何照片。", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "筛选器正在隐藏此视图中的照片:请修改筛选条件,或清除筛选(视图 → 清除筛选)", + "About LightCraft": "关于 LightCraft", + "Activity": "活动", + "Add": "添加", + "Add Catchlight": "添加眼神光", + "Add Folder to Local": "将文件夹添加到本地", + "Add Selected Photos": "添加所选照片", + "Add a rule (⌥-click: a group)": "添加条件(按 ⌥ 单击:添加组)", + "Add in place": "在原位置添加", + "Add in place: the library references the files where they are; nothing is copied or moved.": "在原位置添加:图库直接引用文件所在位置;不会复制或移动任何内容。", + "Add keyword": "添加关键字", + "Add number": "添加编号", + "Add to": "添加到", + "Add to Album": "添加到相册", + "Add to Favorites": "添加到收藏夹", + "Add to My Photos": "添加到我的照片", + "Add to Selected Photos": "添加到所选照片", + "Add/Subtract/Intersect Mask": "添加/减去/相交蒙版", + "Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。", + "Adding": "正在添加", + "After": "修改后", + "After 30 days": "30 天后", + "After 7 days": "7 天后", + "After 90 days": "90 天后", + "After a year": "一年后", + "Album": "相册", + "Albums": "相册", + "All": "全部", + "All Metadata": "所有元数据", + "All Metadata…": "所有元数据…", + "All Photos": "所有照片", + "All rights reserved": "保留所有权利", + "All types": "所有类型", + "Alt Text": "替代文本", + "Always": "始终", + "Amount": "数量", + "Aperture": "光圈", + "Application": "应用程序", + "Apply Preset": "应用预设", + "Approved": "已批准", + "Aqua": "青色", + "Aqua Hue": "青色色相", + "Aqua Luminance": "青色明度", + "Aqua Saturation": "青色饱和度", + "As Shot": "拍摄时设置", + "As shot": "拍摄时设置", + "Ascending": "升序", + "Aspect": "长宽比", + "Aspect Ratio": "长宽比", + "Assisted Culling": "辅助筛选", + "Assisted Culling…": "辅助筛选…", + "Auto": "自动", + "Auto Advance": "自动前进", + "Auto Advance Off": "关闭自动前进", + "Auto Advance On": "开启自动前进", + "Auto Advance after rating or flagging": "评分或加旗标后自动前进", + "Auto Advance: move to the next photo after rating or flagging": "自动前进:评分或加旗标后移到下一张照片", + "Auto Align": "自动对齐", + "Auto Crop": "自动裁剪", + "Auto Import": "自动导入", + "Auto Mask": "自动蒙版", + "Auto Settings": "自动设置", + "Auto Sync": "自动同步", + "Auto-Stack by Capture Time": "按拍摄时间自动堆叠", + "Auto-Stack by Capture Time…": "按拍摄时间自动堆叠…", + "Auto-Tag from Tracklog": "从轨迹日志自动标记", + "Auto-Tag from Tracklog…": "从轨迹日志自动标记…", + "Automatic": "自动", + "Automatically Write Changes into XMP": "自动将更改写入 XMP", + "Automatically write changes into XMP sidecars": "自动将更改写入 XMP 附属文件", + "B&W": "黑白", + "B&W Mix": "黑白混合", + "B&W Mixer": "黑白混色器", + "Back": "返回", + "Back to Edit": "返回编辑", + "Back to Edit (E)": "返回编辑 (E)", + "Back to Grid": "返回网格视图", + "Background": "背景", + "Balance": "平衡", + "Before": "修改前", + "Before/After (Y)": "修改前/修改后 (Y)", + "Before/After Settings": "修改前/修改后设置", + "Before/After Split": "修改前/修改后分屏", + "Before/After Split Top/Bottom": "修改前/修改后上下分屏", + "Before/After Top/Bottom": "修改前/修改后上下排列", + "Bit depth": "位深度", + "Black & White": "黑白", + "Blacks": "黑色色阶", + "Blending": "混合", + "Blue": "蓝色", + "Blue Hue": "蓝色色相", + "Blue Luminance": "蓝色明亮度", + "Blue Primary": "蓝色原色", + "Blue Saturation": "蓝色饱和度", + "Blue channel": "蓝色通道", + "Blue/Yellow Fringe": "蓝/黄边缘", + "Blurry photos below the score are flagged as rejects.": "低于该分值的模糊照片将被标记为排除。", + "Boundary Warp": "边界变形", + "Bring All to Front": "全部置于顶层", + "Browse Folder…": "浏览文件夹…", + "Browse Profiles": "浏览配置文件", + "Browse…": "浏览…", + "Brush": "画笔", + "Brush Stroke": "画笔笔触", + "Build Previews": "构建预览", + "Build Smart Previews": "构建智能预览", + "By Date": "按日期", + "By day (YYYY/YYYY-MM-DD)": "按日 (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "按月 (YYYY/YYYY-MM)", + "Calibration": "校准", + "Camera": "相机", + "Cameras and Cards": "相机和存储卡", + "Cancel": "取消", + "Candidate": "候选", + "Caption": "题注", + "Capture Date": "拍摄日期", + "Captured": "拍摄时间", + "Check All": "全部选中", + "Choose Edit Settings to Copy": "选择要复制的编辑设置", + "Choose Edit Settings to Copy…": "选择要复制的编辑设置…", + "Choose Folder…": "选择文件夹…", + "Choose a photo in the grid or filmstrip": "请在网格视图或胶片窗格中选择一张照片", + "Choose…": "选择…", + "City": "城市", + "Clarity": "清晰度", + "Clear": "清除", + "Clear Cache": "清除缓存", + "Clear Filters": "清除过滤器", + "Clear Guides": "清除参考线", + "Clear History": "清除历史记录", + "Clear Quick Collection": "清空快速集合", + "Clear Reference": "清除参考照片", + "Clear all": "全部清除", + "Click Zoom Ratio": "单击缩放比例", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "单击标签即可插入。示例基于 2026 年 1 月 14 日 05:58:48 拍摄的 IMG_0042.CR3。", + "Click photos in the grid to toggle the keyword; Esc stops": "在网格中单击照片可切换关键字;按 Esc 结束", + "Click the photo to pick a colour · ⇧-click adds more": "单击照片选择颜色 · 按 ⇧ 单击可添加更多", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "单击照片选择颜色;按 ⇧ 单击可添加更多(最多 5 个)", + "Clone": "仿制", + "Close": "关闭", + "Close Panel": "关闭面板", + "Close Window": "关闭窗口", + "Cloudy": "阴天", + "Collapse All Stacks": "折叠所有堆叠", + "Color": "颜色", + "Color Grading": "颜色分级", + "Color Mixer": "颜色混合器", + "Color Noise Reduction": "颜色降噪", + "Color Priority": "颜色优先", + "Color Range": "颜色范围", + "Color space": "色彩空间", + "Color space: sRGB (AVIF)": "色彩空间:sRGB(AVIF)", + "Compare": "比较", + "Compare (Shift+C)": "比较(Shift+C)", + "Compare Before and After": "比较修改前和修改后", + "Compare needs at least two photos": "比较至少需要两张照片", + "Component options": "组件选项", + "Compression": "压缩", + "Confirm before deleting": "删除前确认", + "Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认", + "Constrain Crop": "限制裁剪", + "Contrast": "对比度", + "Copy": "复制", + "Copy After's Settings to Before": "将修改后的设置复制到修改前", + "Copy Before's Settings to After": "将修改前的设置复制到修改后", + "Copy Edit Settings": "复制编辑设置", + "Copy History Step Settings to Before": "将历史记录步骤的设置复制到修改前", + "Copy Metadata": "复制元数据", + "Copy as DNG": "拷贝为 DNG", + "Copy into the library (else use the files where they are)": "拷贝到图库中(否则使用文件当前位置)", + "Copy the files (into the library's Originals/, or a folder you choose)": "拷贝文件(到图库的 Originals/ 或您选择的文件夹)", + "Copy to": "拷贝到", + "Copy to Clipboard": "拷贝到剪贴板", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "拷贝:文件将拷贝到下方文件夹,图库使用这些副本;原始文件保持原样。", + "Copyright": "版权", + "Copyright Info URL": "版权信息 URL", + "Copyright Status": "版权状态", + "Country": "国家/地区", + "Create Album": "创建相册", + "Create Album…": "创建相册…", + "Create Folder": "创建文件夹", + "Create Folder…": "创建文件夹…", + "Create New Mask": "创建新蒙版", + "Create Preset": "创建预设", + "Create Preset…": "创建预设…", + "Create Proof Copy": "创建校样副本", + "Create Smart Album": "创建智能相册", + "Create Smart Album from Filter…": "从筛选器创建智能相册…", + "Create Smart Album…": "创建智能相册…", + "Create Stack": "创建堆叠", + "Create Version": "创建版本", + "Create Version from Step": "从该步骤创建版本", + "Create Virtual Copy": "创建虚拟副本", + "Creator": "创建者", + "Crop": "裁剪", + "Crop & Rotate": "裁剪和旋转", + "Crop & Rotate (C)": "裁剪和旋转(C)", + "Culling": "挑片", + "Curve": "曲线", + "Custom": "自定义", + "Cylindrical": "圆柱形", + "Darken": "压暗", + "Darks": "暗部", + "Date Groups": "日期分组", + "Day": "日", + "Daylight": "日光", + "Defringe": "去边", + "Deghost Amount": "去伪影量", + "Dehaze": "去朦胧", + "Delete": "删除", + "Delete (⌫)": "删除 (⌫)", + "Delete Album": "删除相册", + "Delete All Masks": "删除所有蒙版", + "Delete Keyword": "删除关键字", + "Delete Mask": "删除蒙版", + "Delete Permanently": "永久删除", + "Delete Photo": "删除照片", + "Delete Photos": "删除照片", + "Delete Preset": "删除预设", + "Delete Version": "删除版本", + "Delete all": "删除全部", + "Delete all spots": "删除所有污点", + "Delete mask": "删除蒙版", + "Delete them": "删除它们", + "Density": "密度", + "Depth Range": "深度范围", + "Descending": "降序", + "Deselect All": "取消全选", + "Detail": "细节", + "Detail (D)": "细节 (D)", + "Diagonal": "对角线", + "Discard Preview Cache": "丢弃预览缓存", + "Display P3": "Display P3", + "Distortion": "扭曲", + "Don't enlarge": "不放大", + "Done": "完成", + "Drag along the horizon; double-click for Auto": "沿地平线拖动;双击以自动校正", + "Drag over an eye on the photo; the pupil inside is found automatically.": "在照片中的眼睛上拖动;会自动找到其中的瞳孔。", + "Draw Guides": "绘制参考线", + "Duplicate": "创建副本", + "Duplicate Mask": "复制蒙版", + "Duplicate and Invert Mask": "复制并反转蒙版", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每个 / 开始一层文件夹;标签按照片填充(缺少元数据的层级为“unknown”)。", + "Edit": "编辑", + "Edit (E)": "编辑 (E)", + "Edit Capture Time": "编辑拍摄时间", + "Edit Capture Time…": "编辑拍摄时间…", + "Edit Color Label Names": "编辑颜色标签名称", + "Edit Label Names…": "编辑标签名称…", + "Edit Sections": "编辑板块", + "Edit Smart Album": "编辑智能相册", + "Edit Smart Album…": "编辑智能相册…", + "Edit in External Editor": "在外部编辑器中编辑", + "Edited": "已编辑", + "Effects": "效果", + "Enable Lens Corrections": "启用镜头校正", + "Enter Full Screen": "进入全屏", + "Erase": "擦除", + "Every EXIF, GPS and XMP field in the file": "文件中的每个 EXIF、GPS 和 XMP 字段", + "Existing previews": "现有预览", + "Expand All Stacks": "展开所有堆叠", + "Export": "导出", + "Export Album with Preset": "使用预设导出相册", + "Export Album…": "导出相册…", + "Export Group…": "导出分组…", + "Export Now": "立即导出", + "Export Point Curve Presets": "导出点曲线预设", + "Export Presets": "导出预设", + "Export Presets…": "导出预设…", + "Export User Presets…": "导出用户预设…", + "Export with Preset": "使用预设导出", + "Export with Previous": "使用上次设置导出", + "Exporting": "正在导出", + "Export…": "导出…", + "Exposure": "曝光", + "Exposure (shutter · aperture · ISO)": "曝光 (快门 · 光圈 · ISO)", + "Extended Description": "扩展描述", + "External Editor": "外部编辑器", + "Favorites": "收藏夹", + "Feather": "羽化", + "File": "文件", + "File & date": "文件 & 日期", + "File Name": "文件名", + "File Path": "文件路径", + "File Size": "文件大小", + "File name": "文件名", + "Fill Edges": "填充边缘", + "Filmstrip": "胶片窗格", + "Filter": "过滤器", + "Filter Bar": "过滤器栏", + "Filter Presets": "过滤器预设", + "Filter bar": "过滤器栏", + "Filter fields": "过滤字段", + "Find Dust Spots": "查找灰尘污点", + "Find Missing Photos…": "查找丢失的照片…", + "Find Similar Photos": "查找相似照片", + "Fit": "适合", + "Fit (F)": "适合 (F)", + "Flag": "旗标", + "Flag as Pick": "标记为留用", + "Flag as Reject": "标记为排除", + "Flash": "闪光灯", + "Flip H": "水平翻转", + "Flip Horizontal": "水平翻转", + "Flip Overlay (⇧O)": "翻转叠加 (⇧O)", + "Flip V": "垂直翻转", + "Flip Vertical": "垂直翻转", + "Flow": "流量", + "Fluorescent": "荧光灯", + "Focal length": "焦距", + "Folder": "文件夹", + "Folder name (renamed on disk; its photos follow)": "文件夹名称 (在磁盘上重命名;照片随之更新)", + "Folders": "文件夹", + "Folders and {date} use the capture time; a photo without one uses today's date.": "文件夹和{date}使用拍摄时间;没有拍摄时间的照片使用今天的日期。", + "Forget unchanged photos": "忘记未更改的照片", + "Format": "格式", + "Forward": "前进", + "Free": "自由", + "Full": "完整", + "Full Screen Preview": "全屏预览", + "Full size": "完整大小", + "GPS Track Log": "GPS 轨迹日志", + "General": "常规", + "Geometry": "几何", + "Global Hue": "全局色相", + "Global Luminance": "全局明亮度", + "Global Saturation": "全局饱和度", + "Glossy": "光面纸", + "Golden": "黄金比例", + "Grain": "颗粒", + "Graphic": "图形", + "Green": "绿色", + "Green Amount": "绿色量", + "Green Hue": "绿色色相", + "Green Hue High": "绿色色相上限", + "Green Hue Low": "绿色色相下限", + "Green Luminance": "绿色明亮度", + "Green Primary": "绿色原色", + "Green Saturation": "绿色饱和度", + "Green channel": "绿色通道", + "Grid": "网格视图", + "Grid Info": "网格信息", + "Group": "分组", + "Group by Day": "按日分组", + "Group by Month": "按月分组", + "Group by Year": "按年分组", + "Group by date": "按日期分组", + "Group into Stack": "合并为堆叠", + "Guided": "引导式", + "Guided Upright": "引导式透视校正", + "HDR Merge": "HDR 合并", + "HDR Panorama Merge": "HDR 全景合并", + "HDR Panorama…": "HDR 全景…", + "HDR…": "HDR…", + "Heal": "修复", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑", + "Height": "高度", + "Height (px)": "高度(px)", + "Help": "帮助", + "Hide LightCraft": "隐藏 LightCraft", + "Hide Mask": "隐藏蒙版", + "Hide Others": "隐藏其他", + "Hides this shortcut only; the folder and its photos stay as they are": "仅隐藏此快捷方式;文件夹及其中的照片保持不变", + "High": "高", + "Highlight": "高光", + "Highlights": "高光", + "Highlights Hue": "高光色相", + "Highlights Luminance": "高光明度", + "Highlights Saturation": "高光饱和度", + "Highlights split": "高光分界", + "Histogram": "直方图", + "History": "历史记录", + "History & Activity (Y)": "历史记录与活动(Y)", + "Horizontal": "水平", + "Hue": "色相", + "Hue Range": "色相范围", + "If file exists": "如果文件已存在", + "Import": "导入", + "Import Date": "导入日期", + "Import Photos": "导入照片", + "Import Photos…": "导入照片…", + "Import Point Curve Presets": "导入点曲线预设", + "Import Presets": "导入预设", + "Import Presets…": "导入预设…", + "Import Profiles & Presets…": "导入配置文件与预设…", + "Import from Device": "从设备导入", + "Import from Folder…": "从文件夹导入…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "使用“文件 → 导入照片…”(Cmd+Shift+I)导入照片,或将照片拖放到此处", + "Imported Photos": "已导入的照片", + "Importing": "正在导入", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "导入会扫描源中的照片,但不会将该文件夹添加到“本地”(若想在不导入的情况下处理某个文件夹,请使用“本地 → 浏览文件夹…”)。", + "In library": "在图库中", + "In memory — nothing is saved": "仅存于内存 — 不会保存任何内容", + "In use": "使用中", + "In-memory session: these settings last until LightCraft quits.": "内存会话:这些设置仅在 LightCraft 退出前有效。", + "Include subfolders": "包含子文件夹", + "Info": "信息", + "Info (I)": "信息(I)", + "Info overlay": "信息叠加显示", + "Interface": "界面", + "Intersect": "相交", + "Intersect with": "相交于", + "Into one folder": "合并到一个文件夹", + "Invert": "反相", + "Invert Mask": "反相蒙版", + "Keep in Local": "保留在本地", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "请选择空间充足的驱动器:智能预览每张照片约 1 MB。缩略图缓存仍保留在图库中。", + "Keyboard Shortcuts": "键盘快捷键", + "Keyboard shortcuts": "键盘快捷键", + "Keyword": "关键字", + "Keyword Painter": "关键字画笔", + "Keyword Set": "关键字集", + "Keyword Sets": "关键字集", + "Keyword Suggestions": "关键字建议", + "Keyword to paint": "要涂抹的关键字", + "Keywords": "关键字", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "您添加的关键字会显示在此处;按 ⌥1–⌥9 即可应用。", + "Landscape": "横向", + "Language": "语言", + "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "详细视图渲染的最大长边;在大显示器上越大越清晰,但速度更慢。", + "Last": "上次", + "Last view": "上次视图", + "Lateral Chromatic Aberration": "横向色差", + "Leave them": "保留", + "Lens": "镜头", + "Lens Corrections": "镜头校正", + "Level": "水平", + "Library": "图库", + "Library Originals": "图库原始文件", + "Light": "亮度", + "LightCraft Default": "LightCraft 默认", + "LightCraft Help": "LightCraft 帮助", + "LightCraft Preset": "LightCraft 预设", + "LightCraft Presets": "LightCraft 预设", + "LightCraft Website": "LightCraft 网站", + "LightCraft on GitHub": "GitHub 上的 LightCraft", + "LightCraft website": "LightCraft 网站", + "LightCraft — Second Window": "LightCraft — 第二窗口", + "Lights": "高光", + "Limit file size (KB, 0 = off)": "限制文件大小(KB,0 = 关闭)", + "Linear Gradient": "线性渐变", + "List this folder in Local from now on": "以后在本地中列出此文件夹", + "Local": "本地", + "Local folders": "本地文件夹", + "Local library — no cloud account needed": "本地图库 — 无需云账户", + "Locate Missing File…": "定位缺失的文件…", + "Location": "位置", + "Long Edge": "长边", + "Looking for photos…": "正在查找照片…", + "Lossless": "无损", + "Low": "低", + "Luminance": "明度", + "Luminance Range": "明度范围", + "Magenta": "品红色", + "Magenta Hue": "品红色色相", + "Magenta Luminance": "品红色明度", + "Magenta Saturation": "品红色饱和度", + "Make Candidate the Select": "将候选设为选择", + "Manual": "手动", + "Masking": "蒙版", + "Masking (M)": "蒙版 (M)", + "Masks": "蒙版", + "Match": "匹配", + "Matte": "哑光", + "Med": "中", + "Megapixels": "百万像素", + "Memory for caches": "缓存内存", + "Menu": "菜单", + "Merge": "合并", + "Merge Keywords": "合并关键字", + "Merge into…": "合并到…", + "Merging…": "正在合并…", + "Metadata": "元数据", + "Metadata Preset": "元数据预设", + "Metadata Presets": "元数据预设", + "Metadata preset": "元数据预设", + "Midpoint": "中点", + "Midtones": "中间调", + "Midtones Hue": "中间调色相", + "Midtones Luminance": "中间调明亮度", + "Midtones Saturation": "中间调饱和度", + "Midtones split": "中间调分割点", + "Minimize": "最小化", + "Missing Photos": "缺失的照片", + "Mode": "模式", + "Modified Date": "修改日期", + "Monochrome": "黑白", + "Month": "月", + "More": "更多", + "More preset options": "更多预设选项", + "Move": "移动", + "Move Down in Stack": "在堆叠中下移", + "Move Folder To…": "将文件夹移动到…", + "Move Up in Stack": "在堆叠中上移", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "移动文件 (移到图库的 Originals/,或你选择的文件夹),并从源位置移除", + "Move them": "移动它们", + "Move to": "移动到", + "Move to Group": "移动到组", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "移动:文件将移动到下面的文件夹;每个原始文件 (及其 XMP 附属文件) 仅在副本校验通过后才会从源位置移除。重复的文件和失败的文件保留在原处。", + "My Photos": "我的照片", + "Name": "名称", + "Named": "已命名", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "名称会显示在标签菜单、筛选栏和信息面板中,并写入 XMP。留空 = 颜色的名称。", + "Navigator": "导航器", + "Needs Work": "待处理", + "Neutral": "中性", + "Never": "从不", + "New Album…": "新建相册…", + "New Folder…": "新建文件夹…", + "New Group…": "新建组…", + "New Smart Album": "新建智能相册", + "New Smart Album from Filter…": "从筛选器新建智能相册…", + "New Smart Album…": "新建智能相册…", + "New album": "新建相册", + "New album…": "新建相册…", + "New name": "新名称", + "No": "否", + "No Camera or Card Found": "未找到相机或存储卡", + "No Date Groups": "不按日期分组", + "No Stars": "无星级", + "No Tool": "无工具", + "No automatic versions.": "没有自动版本。", + "No camera": "无相机信息", + "No date": "无日期", + "No dust spots found": "未找到污点", + "No edits yet.": "尚未编辑。", + "No exposure information": "无曝光信息", + "No filter presets yet": "暂无筛选器预设", + "No lens data embedded in this file (DNG lens opcodes only).": "此文件中未嵌入镜头数据 (仅支持 DNG 镜头操作码)。", + "No masks yet. Choose a mask type above.": "尚无蒙版。请在上方选择蒙版类型。", + "No matching photos": "没有匹配的照片", + "No metadata found": "未找到元数据", + "No new presets": "没有新预设", + "No photo selected": "未选择照片", + "No photos": "没有照片", + "No photos found": "未找到照片", + "No photos in this folder": "此文件夹中没有照片", + "No photos match the active filters": "没有照片符合当前筛选条件", + "No photos match the filters (View → Clear Filters)": "没有照片符合筛选条件 (视图 → 清除筛选)", + "No raw photos yet: cameras appear here once their photos are in the library.": "尚无 RAW 照片:照片进入图库后,相机会显示在此处。", + "No reference photo": "无参考照片", + "No usable GPU found: rendering on the CPU": "未找到可用的 GPU:改用 CPU 渲染", + "No versions yet. Create one to keep this look.": "尚无版本。创建一个以保留当前效果。", + "Noise": "噪点", + "Noise Reduction": "降噪", + "Non-raw photos": "非 RAW 照片", + "None": "无", + "Nothing to compare": "没有可比较的照片", + "Nothing to survey": "没有可筛选的照片", + "OK": "确定", + "Object": "对象", + "Off": "关闭", + "Offset X": "水平偏移", + "Offset Y": "垂直偏移", + "Opacity": "不透明度", + "Opacity (%)": "不透明度(%)", + "Open Library": "打开图库", + "Open Library…": "打开图库…", + "Open in": "打开方式", + "Open in Detail": "在放大视图中打开", + "Open the place in OpenStreetMap": "在 OpenStreetMap 中打开该地点", + "Optics": "镜头校正", + "Optional name": "名称(可选)", + "Orange": "橙色", + "Orange Hue": "橙色色相", + "Orange Luminance": "橙色明亮度", + "Orange Saturation": "橙色饱和度", + "Original": "原始比例", + "Original files": "原始文件", + "Original offline · editing the smart preview": "原图离线 · 正在编辑智能预览", + "Original offline · no smart preview": "原图离线 · 无智能预览", + "Originals": "原图", + "Other images (JPEG, PNG, TIFF, HEIC…)": "其他图像(JPEG、PNG、TIFF、HEIC…)", + "Overlay": "叠加显示", + "Overwrite": "覆盖", + "Paint": "绘制", + "Paint only areas like the one under the brush": "仅绘制与画笔下相似的区域", + "Paint over a distraction on the photo to remove it.": "在照片上要移除的干扰物上涂抹。", + "Paint overlay": "蒙版叠加显示", + "Panorama Merge": "全景合并", + "Panorama…": "全景…", + "Parametric curve": "参数曲线", + "Paste Edit Settings": "粘贴编辑设置", + "Paste Metadata": "粘贴元数据", + "Paste Selected Settings": "粘贴所选设置", + "Paste Selected Settings…": "粘贴所选设置…", + "Percent": "百分比", + "Percentage": "百分比", + "Performance": "性能", + "Perspective": "透视", + "Pet Eye": "宠物眼", + "Photo": "照片", + "Photo Grid": "照片网格", + "Photo Grid (G)": "照片网格(G)", + "Photo Merge": "照片合并", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "“照片 » 在外部编辑器中编辑”(⇧⌘E)会渲染一份 16 位 TIFF 副本,与原图堆叠并在此打开(macOS 上填应用名称,其他平台填程序路径)。", + "Photos": "照片", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "仅在“本地”中查看过、从未添加或更改的照片,若其文件夹在此时长内未被浏览,将移出目录(打开图库时检查)。文件保留在磁盘上;浏览该文件夹即可再次显示。", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "进入此文件夹的照片(联机拍摄、扫描仪、同步应用)在传输完成后即被添加。", + "Pick": "留用", + "Pick a colour on the photo with the eyedropper.": "用吸管在照片上取色。", + "Pick the sharpest photo of each burst": "为每组连拍选取最清晰的照片", + "Picked": "已留用", + "Picks": "留用", + "Pixels": "像素", + "Point Color": "点颜色", + "Point Curve": "点曲线", + "Point Curve Presets": "点曲线预设", + "Point curve": "点曲线", + "Portrait": "人像", + "Position": "位置", + "Preset": "预设", + "Preset name": "预设名称", + "Presets": "预设", + "Presets & Profiles": "预设和配置文件", + "Presets (Shift+P)": "预设 (Shift+P)", + "Presets ▾": "预设 ⌄", + "Preview": "预览", + "Preview only": "仅预览", + "Preview size": "预览大小", + "Previews": "预览", + "Print": "打印", + "Profile": "配置文件", + "Profile Distortion": "配置文件扭曲", + "Profile Vignetting": "配置文件暗角", + "Profile:": "配置文件:", + "Profiles": "配置文件", + "Projection": "投影", + "Public domain": "公有领域", + "Pupil Size": "瞳孔大小", + "Purple": "紫色", + "Purple Amount": "紫色量", + "Purple Hue": "紫色色相", + "Purple Hue High": "紫色色相上限", + "Purple Hue Low": "紫色色相下限", + "Purple Luminance": "紫色亮度", + "Purple Saturation": "紫色饱和度", + "Put the locations you removed from Local back (no files change)": "恢复你从本地移除的位置 (不更改文件)", + "Quality": "品质", + "Quick Collection": "快速集合", + "Quick Develop": "快速编辑", + "Quit LightCraft": "退出 LightCraft", + "Radial Gradient": "径向渐变", + "Radius": "半径", + "Range": "范围", + "Rating": "星级", + "Ratings & flags": "星级和旗标", + "Raw default": "RAW 默认值", + "Raw defaults": "RAW 默认值", + "Raw files": "RAW 文件", + "Raw photos": "RAW 照片", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "将 RAW 照片导出为内嵌编辑设置的 DNG。大小、颜色和输出选项不适用。", + "Read Metadata from File": "从文件读取元数据", + "Reading": "正在读取", + "Reading folder…": "正在读取文件夹…", + "Recently Added": "最近添加", + "Recently Deleted": "最近删除", + "Red": "红", + "Red Eye": "红眼", + "Red Hue": "红色色相", + "Red Luminance": "红色亮度", + "Red Primary": "红色原色", + "Red Saturation": "红色饱和度", + "Red channel": "红色通道", + "Red/Cyan Fringe": "红/青边缘", + "Redo": "重做", + "Reference": "参考", + "Reference View": "参考视图", + "Reference the files where they are": "在原地引用文件", + "Refresh Source (/)": "刷新源 (/)", + "Reject below focus": "排除对焦低于此值的照片", + "Rejected": "已排除", + "Rejects": "排除的照片", + "Remove": "移除", + "Remove (H)": "移除 (H)", + "Remove CA": "移除色差", + "Remove Chromatic Aberration": "移除色差", + "Remove from Album": "从相册中移除", + "Remove from Favorites": "从收藏夹中移除", + "Remove from Local": "从本地移除", + "Remove from Photo": "从照片中移除", + "Remove from Selected Photos": "从所选照片中移除", + "Remove from Stack": "从堆叠中移出", + "Remove from survey": "从筛选视图中移除", + "Remove location info": "移除位置信息", + "Remove this filter": "移除此筛选器", + "Remove this group": "删除此组", + "Remove this rule": "删除此规则", + "Rename": "重命名", + "Rename Album": "重命名相册", + "Rename Folder…": "重命名文件夹…", + "Rename Keyword": "重命名关键字", + "Rename Keyword…": "重命名关键字…", + "Rename Photos": "重命名照片", + "Rename Photos…": "重命名照片…", + "Rename Version": "重命名版本", + "Rename…": "重命名…", + "Rendering": "正在渲染", + "Rendering…": "正在渲染…", + "Reset": "重置", + "Reset All Curves": "重置所有曲线", + "Reset Before to Import State": "将修改前重置为导入时的状态", + "Reset Control": "重置控件", + "Reset Crop": "重置裁剪", + "Reset Edits": "重置编辑", + "Reset Parametric Curve": "重置参数曲线", + "Reset Section": "重置此区段", + "Reset Tone Curve": "重置色调曲线", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重置所有曲线:点曲线(所有通道)和参数曲线(双击某个通道可单独重置该通道)", + "Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)", + "Resize to": "调整大小为", + "Resolution (ppi)": "分辨率 (ppi)", + "Restore": "恢复", + "Retouch": "润饰", + "Review Import": "查看导入内容", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "右键单击照片 » 设为参考照片,然后选择“视图 → 参考视图”(Shift+R)", + "Right-click a preset to delete it": "右键单击预设即可将其删除", + "Rights Usage Terms": "使用条款", + "Rotate": "旋转", + "Rotate Left": "向左旋转", + "Rotate Right": "向右旋转", + "Roughness": "粗糙度", + "Roundness": "圆度", + "Same as raw default": "与 RAW 默认设置相同", + "Sample a colour on the photo": "在照片上取样颜色", + "Saturation": "饱和度", + "Saturation Range": "饱和度范围", + "Save": "保存", + "Save Current Filter as Preset…": "将当前筛选器保存为预设…", + "Save Current Keywords as Set…": "将当前关键字保存为集…", + "Save Metadata to File": "将元数据保存到文件", + "Save Point Curve…": "保存点曲线…", + "Save as Preset": "保存为预设", + "Save as Smart Album…": "保存为智能相册…", + "Save as set": "保存为集", + "Save preset": "保存预设", + "Scale": "缩放", + "Scanning": "正在扫描", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "评分会保留在照片上:可使用“对焦”和“相似照片中的最佳”进行筛选或创建智能相册。", + "Screen": "屏幕", + "Search Photos": "搜索照片", + "Second Window": "第二个窗口", + "Select": "选择", + "Select All": "全选", + "Select Luminance Range": "选择亮度范围", + "Select a photo to edit": "选择要编辑的照片", + "Select by": "选择方式", + "Select by Flag, Rating or Label": "按旗标、星级或颜色标签选择", + "Select photos, then choose View → Survey (N)": "选择照片,然后选择 视图 → 筛选 (N)", + "Select two photos, then choose View → Compare (Shift+C)": "选择两张照片,然后选择 视图 → 比较 (Shift+C)", + "Selected": "已选择", + "Send Feedback…": "发送反馈…", + "Services": "服务", + "Set Color Label": "设置颜色标签", + "Set Develop Values": "设置编辑参数", + "Set Flag": "设置旗标", + "Set Point Curve": "设置点曲线", + "Set Profile": "设置配置文件", + "Set Rating": "设置星级", + "Set as Album Cover": "设为相册封面", + "Set as Before": "设为修改前", + "Set as Reference Photo": "设为参考照片", + "Set as Target Album": "设为目标相册", + "Set as Target Album (B adds to it)": "设为目标相册 (按 B 添加)", + "Set as Top of Stack": "设为堆叠顶部", + "Set the mix from the photo's colours": "根据照片颜色设置混合", + "Settings": "设置", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片的初始设置。更改这些设置不会影响图库中已有的照片。", + "Settings to include": "要包含的设置", + "Settings…": "设置…", + "Shade": "阴影", + "Shadow": "阴影", + "Shadows": "阴影", + "Shadows Hue": "阴影色相", + "Shadows Luminance": "阴影明亮度", + "Shadows Saturation": "阴影饱和度", + "Shadows Tint": "阴影色调", + "Shadows split": "阴影分界", + "Sharpen": "锐化", + "Sharpening": "锐化", + "Sharpness": "锐度", + "Short Edge": "短边", + "Show All": "显示全部", + "Show Clipping": "显示溢出警告", + "Show Deghost Overlay": "显示去重影叠加", + "Show Hidden Local Locations": "显示隐藏的本地位置", + "Show Luminance Map": "显示明亮度图", + "Show Mask": "显示蒙版", + "Show Mask Overlay": "显示蒙版叠加", + "Show Mask Pins": "显示蒙版图钉", + "Show Original": "显示原图", + "Show Photo Counts": "显示照片数量", + "Show Photos with Keyword": "显示包含该关键字的照片", + "Show Pins": "显示图钉", + "Show Source": "显示来源", + "Show Thumbnails": "显示缩略图", + "Show destination gamut warning": "显示目标色域警告", + "Show display gamut warning": "显示显示器色域警告", + "Show file names": "显示文件名", + "Show in Finder": "在 Finder 中显示", + "Show on Map": "在地图上显示", + "Show overlay (O)": "显示叠加 (O)", + "Show ratings, flags and edit badges": "显示星级、旗标和编辑标记", + "Show the Navigator while zoomed in": "放大时显示导航器", + "Show the template tags; click one to insert it": "显示模板标签;单击即可插入", + "Show/hide My Photos panel": "显示/隐藏我的照片面板", + "Shutter speed": "快门速度", + "Sidecar names": "附属文件名", + "Size": "大小", + "Size (% of short edge)": "大小 (占短边 %)", + "Size limit": "大小限制", + "Skip": "跳过", + "Sky": "天空", + "Smart Album": "智能相册", + "Smart previews": "智能预览", + "Smoothness": "平滑度", + "Soft Proofing": "软打样", + "Sort": "排序", + "Sort by": "排序方式", + "Source": "来源", + "Source code on GitHub": "GitHub 上的源代码", + "Spherical": "球面", + "Spiral": "螺旋", + "Square Grid": "正方形网格", + "Square Grid (G toggles)": "正方形网格(按 G 切换)", + "Square Grid: show file names and formats": "正方形网格:显示文件名和格式", + "Stack": "堆叠", + "Stack photos taken within this time of each other:": "将拍摄时间在此间隔内的照片堆叠在一起:", + "Stacks": "堆叠", + "Standard": "标准", + "Start at": "起始编号", + "Start number": "起始编号", + "Startup": "启动", + "State / Province": "省 / 州", + "Stop": "停止", + "Stop Building Previews": "停止构建预览", + "Stop Using as Target Album": "不再用作目标相册", + "Stopping…": "正在停止…", + "Straighten": "拉直", + "Straighten Tool": "拉直工具", + "Style": "样式", + "Subfolder": "子文件夹", + "Subfolder name": "子文件夹名称", + "Subject": "主题", + "Subtract": "减去", + "Suggestions": "建议", + "Survey": "筛选视图", + "Survey (N)": "筛选视图(N)", + "Swap": "交换", + "Swap Aspect (X)": "交换长宽比(X)", + "Swap Before and After Settings": "交换修改前和修改后的设置", + "Sync Settings": "同步设置", + "System Info": "系统信息", + "System Info…": "系统信息…", + "System default": "系统默认", + "Tags": "标签", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "标签:请参阅模板旁的“标签”。文件将在磁盘上重命名(连同其 XMP 附属文件);已有同名文件会得到 -1、-2…", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "局部调整:在照片上上下拖动以调整该处的颜色", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "局部调整:在照片上上下拖动以调整该处的曲线", + "Temp": "色温", + "Temperature": "色温", + "Template": "模板", + "Text": "文字", + "Texture": "纹理", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "保留原始文件不变,每个文件附带一个保存其编辑内容的 XMP 附属文件。", + "The photo has these settings": "此照片具有这些设置", + "They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。", + "Thirds": "三分法", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此文件夹不可用(驱动器已断开连接?)。在它恢复或您选择其他文件夹之前,不会构建或使用智能预览。", + "This photo can't be opened": "无法打开此照片", + "Threshold": "阈值", + "Thumbnail cache": "缩略图缓存", + "Tint": "色调", + "Title": "标题", + "Tone Curve": "色调曲线", + "Tools": "工具", + "Top Level": "顶层", + "Transfer": "传输", + "Treatment": "处理方式", + "Triangle": "三角形", + "Tungsten": "钨丝灯", + "Turn Off": "关闭", + "Turn on Include subfolders, or pick another folder under Local": "开启“包括子文件夹”,或在“本地”下选择其他文件夹", + "Uncheck All": "全部取消选中", + "Undo": "撤销", + "Unedited": "未编辑", + "Unflag": "取消旗标", + "Unflagged": "无旗标", + "Ungroup Stack": "取消堆叠", + "Unrated": "未评级", + "Unreadable": "无法读取", + "Update": "更新", + "Update Rules from Current Filter": "从当前筛选器更新规则", + "Update Version": "更新版本", + "Update with Current Settings": "使用当前设置更新", + "Upright": "垂直校正", + "Use Library Folder": "使用图库文件夹", + "Use camera-specific defaults": "使用相机专属默认值", + "Use the GPU for rendering": "使用 GPU 渲染", + "User Presets": "用户预设", + "Value": "值", + "Variance": "变化幅度", + "Version": "版本", + "Version name": "版本名称", + "Versions": "版本", + "Versions (Shift+V)": "版本 (Shift+V)", + "Vertical": "垂直", + "Vibrance": "鲜艳度", + "Videos": "视频", + "View": "视图", + "Vignette": "暗角", + "Vignetting": "暗角", + "Visualize Spots": "可视化斑点", + "Visualize Spots (A)": "可视化斑点 (A)", + "Visualize range": "显示范围", + "Vivid": "鲜艳", + "Watched folder": "监视的文件夹", + "Watermark": "水印", + "What's New": "新功能", + "When rated or hovered": "评级或悬停时", + "White Balance": "白平衡", + "White Balance Selector": "白平衡选择器", + "White Balance Selector (W)": "白平衡选择器 (W)", + "Whites": "白色色阶", + "Width": "宽度", + "Width (% of photo)": "宽度(照片百分比)", + "Width (px)": "宽度 (px)", + "Width × Height": "宽度 × 高度", + "Window": "窗口", + "XMP sidecars": "XMP 附属文件", + "Year": "年", + "Yellow": "黄色", + "Yellow Hue": "黄色色相", + "Yellow Luminance": "黄色明度", + "Yellow Saturation": "黄色饱和度", + "Yes": "是", + "Zoom": "缩放", + "Zoom 100%": "缩放 100%", + "Zoom In": "放大", + "Zoom Out": "缩小", + "Zoom to Fit": "适合窗口", + "and": "和", + "comma, separated": "逗号分隔", + "memory only": "仅内存", + "none": "无", + "not available here": "此处不可用", + "of the following rules:": "以下规则:", + "Effect": "效果", + "Image": "图像", + "Layer": "图层", + "Type": "类型", + "Save As…": "另存为…", + "Exit": "退出", + "New…": "新建…", + "New": "新建", + "Orientation": "方向", + "Layers": "图层", + "Properties": "属性", + "Brush Settings": "画笔设置", + "Options": "选项", + "Fit on Screen": "适合屏幕", + "Cut": "剪切", + "Paste": "粘贴", + "Deselect": "取消选择", + "Export As…": "导出为…", + "Search…": "搜索…", + "Theme": "主题", + "Pages": "页面", + "Preferences": "首选项", + "Preferences…": "首选项…", + "Interface language": "界面语言", + "Open…": "打开…", + "New blank PDF": "新建空白 PDF", + "Create PDF from file…": "从文件创建 PDF…", + "Create PDF from images…": "从图像创建 PDF…", + "Create PDF from clipboard": "从剪贴板创建 PDF", + "Combine files…": "合并文件…", + "Save as…": "另存为…", + "Close file": "关闭文件", + "Close all": "全部关闭", + "Revert": "恢复", + "Print…": "打印…", + "Document properties…": "文档属性…", + "Find…": "查找…", + "Advanced search…": "高级搜索…", + "Copy pages": "复制页面", + "Cut pages": "剪切页面", + "Paste pages": "粘贴页面", + "Fit visible": "适合可见区域", + "Marquee zoom": "框选缩放", + "Take a snapshot": "创建快照", + "Full screen mode": "全屏模式", + "Read mode": "阅读模式", + "Switch light / dark theme": "切换浅色/深色主题", + "Comments panel": "注释面板", + "Form fields panel": "表单字段面板", + "Clear form": "清除表单", + "Find tools and commands…": "查找工具和命令…", + "Actual size": "实际大小", + "Zoom to page level": "缩放至页面级别", + "Fit to width": "适合宽度", + "Display theme": "显示主题", + "Side panels": "侧面板", + "Text direction": "文字方向", + "Horizontal text": "横排文字", + "Vertical text": "竖排文字", + "Your library couldn't be opened": "无法打开图库", + "LightCraft couldn't find where to keep your library.": "LightCraft 找不到保存图库的位置。", + "LightCraft couldn't open the library at": "LightCraft 无法打开以下位置的图库:", + "Try Again": "重试", + "Choose Another Library…": "选择其他图库…", + "Continue Without Saving": "不保存继续", + "Quit": "退出", + "Temporary session — your library isn't open, and nothing you do here is saved.": "临时会话 — 图库未打开,此处所做的任何操作都不会保存。", + "Back Up Library…": "备份图库…", + "Restore Library from Backup…": "从备份恢复图库…", + "In Recently Deleted": "在“最近删除”中", + "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 9e1f09252..b592557a7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -55,7 +55,7 @@ fn wrap(r: Result) -> Outcome { pub fn all_commands(app: &LightcraftApp) -> Value { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); - for (id, label, sc, menu) in crate::menus::UI_COMMANDS { + for (id, label, sc, menu) in crate::menus::ui_commands() { v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true})); } Value::Array(v) @@ -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/headless.rs b/crates/ui-egui/src/headless.rs index a78fe6617..a6b8fc432 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1236,6 +1236,87 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Adding a file again that is in Recently Deleted shows it there (side panel opened, photo + /// selected) instead of only saying "duplicate skipped"; its menus offer Restore, and once + /// restored a re-add selects it in All Photos. + #[test] + fn readding_a_deleted_photo_shows_it_in_recently_deleted() { + let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 16, height: 12, data: vec![[200, 120, 40, 255]; 16 * 12] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let file = dir.join("flower.png"); + std::fs::write(&file, png).unwrap(); + let paths = vec![file.to_string_lossy().to_string()]; + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + let t = Duration::from_secs(10); + // frames until the import has finished, so its (timed) toast is read before it expires + let finish_import = |h: &mut Headless| { + let t0 = std::time::Instant::now(); + while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) { + h.step(); + } + assert!(h.app.import.is_none(), "import finished"); + }; + crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); + h.settle(SETTLE); + let id = h.app.session.selection.active.expect("imported photo selected"); + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); + assert!(h.app.session.catalog.photo(id).unwrap().deleted); + let photo_items = |app: &LightcraftApp| -> Vec { + let bar = crate::menubar::menu_bar(app); + let items = &bar.iter().find(|(title, _)| title == "Photo").expect("Photo menu").1; + items.iter().filter_map(|n| if let crate::menubar::MenuNode::Item { id, .. } = n { Some(id.clone()) } else { None }).collect() + }; + assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos"); + + h.app.ui.left_panel = false; + crate::import::start_paths(&mut h.app, paths.clone()).unwrap(); + finish_import(&mut h); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted); + assert_eq!(h.app.session.selection.ids, vec![id]); + assert!(h.app.ui.left_panel, "the side panel listing Recently Deleted is opened"); + let toast = h.app.ui.toast.clone().expect("toast").0; + assert!(toast.contains("Recently Deleted") && toast.contains("Restore"), "{toast}"); + let items = photo_items(&h.app); + assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}"); + assert!(!items.contains(&"photo.delete".to_string()), "{items:?}"); + // a right-click on its filmstrip thumbnail opens the photo menu + h.request("ui.set", json!({"view": "detail"}), t); + h.settle(SETTLE); + let cell = h.app.widgets.iter().find(|(w, _)| *w == format!("film:{}", id.0)).map(|(_, r)| *r).expect("filmstrip cell"); + assert!(!egui::Popup::is_any_open(&h.view.ctx)); + let r = h.request("ui.click", json!({"x": cell.center().x, "y": cell.center().y, "button": "right"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu"); + + let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored"); + crate::import::start_paths(&mut h.app, paths).unwrap(); + finish_import(&mut h); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.selection.ids, vec![id]); + let toast = h.app.ui.toast.clone().expect("toast").0; + assert!(toast.contains("All Photos"), "{toast}"); + + // deleted permanently, the file imports afresh as a new photo + h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t); + h.request("engine.execute", json!({"command": "photo.deletePermanently", "params": {"ids": [id.0]}}), t); + assert!(h.app.session.catalog.photo(id).is_none()); + crate::import::start_paths(&mut h.app, vec![file.to_string_lossy().to_string()]).unwrap(); + h.settle(SETTLE); + let fresh = h.app.session.selection.active.expect("re-imported photo selected"); + assert_ne!(fresh, id); + assert!(!h.app.session.catalog.photo(fresh).unwrap().deleted); + let _ = std::fs::remove_dir_all(&dir); + } + /// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across /// the import's batches. #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0e2389a44..3fc906449 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1,69 +1,274 @@ //! Presentation-only localization. Command ids, user names and catalog data remain unchanged. -use std::{cell::Cell, collections::BTreeMap, sync::OnceLock}; +//! +//! A language is one entry in [`language_table!`] below: its BCP-47 code, the endonym shown in the +//! Language menus, the ISO 15924 script its text needs (which picks the CJK font fallback), and its +//! message catalog (`locales/.json`, embedded at build time). Adding a language is that entry +//! plus its catalogs — every menu and settings surface is built from the table, so nothing else in +//! the UI changes. -use serde::{Deserialize, Serialize}; +use std::{cell::RefCell, collections::BTreeMap, sync::OnceLock}; -include!(concat!(env!("OUT_DIR"), "/ja-formats.rs")); +use serde::{Deserialize, Deserializer, Serialize, Serializer}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Language { - #[default] - #[serde(rename = "en")] - En, - #[serde(rename = "ja")] - Ja, +/// The language table. +/// +/// The source language comes first and alone: it is the text the UI is authored in, which every +/// other language translates. `name` is written in the language itself (an endonym, so a user who +/// cannot read the current UI language can still find their own). +macro_rules! language_table { + ($source:ident, $source_code:literal; $( $variant:ident, $code:literal, $name:literal, $script:literal, $catalog:expr );* $(;)?) => { + /// A UI language. + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] + pub enum Locale { + /// The language the UI is written in. + #[default] + $source, + $($variant,)* + } + + impl Locale { + /// Every language, in menu order (the source language first). + pub const ALL: &'static [Self] = &[Self::$source, $(Self::$variant,)*]; + + pub const fn code(self) -> &'static str { + match self { Self::$source => $source_code, $(Self::$variant => $code,)* } + } + + /// The language's own name, as shown in the Language menus. + pub const fn name(self) -> &'static str { + match self { Self::$source => "English", $(Self::$variant => $name,)* } + } + + /// The ISO 15924 script this language is written in (`"Latn"`, `"Jpan"`, `"Hans"`…): + /// the craft-fonts faces for it are the ones this language needs. + pub const fn script(self) -> &'static str { + match self { Self::$source => "Latn", $(Self::$variant => $script,)* } + } + + /// The language's embedded catalog, parsed once. The source language has none: its + /// lookups return the source text itself. + fn catalog(self) -> &'static BTreeMap { + static CATALOGS: OnceLock>> = OnceLock::new(); + static EMPTY: OnceLock> = OnceLock::new(); + // A malformed catalog logs and comes back empty: a broken translation degrades to + // English text, it never takes the app down. + let parse = |catalog: Option<&'static str>| match catalog { + Some(json) => match serde_json::from_str(json) { + Ok(messages) => messages, + Err(error) => { + log::error!("Invalid message catalog for {}: {error}", self.code()); + BTreeMap::new() + } + }, + None => BTreeMap::new(), + }; + CATALOGS + .get_or_init(|| { + Locale::ALL + .iter() + .map(|language| { + let catalog = match *language { $(Self::$variant => Some($catalog),)* _ => None }; + (*language, parse(catalog)) + }) + .collect() + }) + .get(&self) + .unwrap_or_else(|| EMPTY.get_or_init(BTreeMap::new)) + } + + pub fn parse(code: &str) -> Option { + match code { $source_code => Some(Self::$source), $($code => Some(Self::$variant),)* _ => None } + } + + /// Lenient lookup: `zh`, `zh-CN`, `zh_Hans`, `en-US`, `ja_JP.UTF-8`… all reach a + /// shipped language. A region or script the table does not ship falls back to the + /// language itself, so a user is never left with no language at all. + pub fn parse_tag(tag: &str) -> Option { + let tag = tag.split(['.', '@']).next().unwrap_or(tag).replace('_', "-"); + let lower = tag.to_ascii_lowercase(); + if let Some(exact) = Self::ALL.iter().find(|language| language.code().eq_ignore_ascii_case(&lower)) { + return Some(*exact); + } + let mut parts = lower.split('-'); + let language = parts.next()?; + // `zh-Hans-CN`, `zh-CN` and `zh` all name the same base language. + let script = parts.next().and_then(|part| match part { + "hans" | "cn" | "sg" => Some("Hans"), + "hant" | "tw" | "hk" | "mo" => Some("Hant"), + _ => None, + }); + Self::ALL + .iter() + .find(|candidate| { + let base = candidate.code().split('-').next().unwrap_or_default(); + base == language && script.is_none_or(|script| candidate.script() == script) + }) + .copied() + } + + pub fn tr(self, source: &str) -> &str { + tr_in(self, source) + } + } + }; } -impl Language { - pub const ALL: [Self; 2] = [Self::En, Self::Ja]; - pub fn name(self) -> &'static str { - match self { - Self::En => "English", - Self::Ja => "日本語", - } +// The source language, then one entry per translation: code, endonym, ISO 15924 script, embedded +// catalog (`locales/.json`). +language_table! { + En, "en"; + ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json"); + ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json"); + Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json"); +} + +// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a +// language can be renamed in code without invalidating anyone's settings. +impl Serialize for Locale { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.code()) } - pub fn parse(code: &str) -> Option { - match code { - "en" => Some(Self::En), - "ja" => Some(Self::Ja), - _ => None, - } - } - pub fn tr(self, source: &str) -> &str { - if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source } +} + +impl<'de> Deserialize<'de> for Locale { + fn deserialize>(deserializer: D) -> Result { + let code = String::deserialize(deserializer)?; + Locale::parse(&code).or_else(|| Locale::parse_tag(&code)).ok_or_else(|| serde::de::Error::custom(format!("unknown language {code:?}"))) } } thread_local! { - static LANGUAGE: Cell = const { Cell::new(Language::En) }; + static LOCALE: std::cell::Cell = const { std::cell::Cell::new(Locale::En) }; } -pub fn default_language() -> Language { - if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En } +/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs. +pub fn default_language() -> Locale { + std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En) } -pub fn set_language(language: Language) { - LANGUAGE.with(|value| value.set(language)); +pub fn set_language(language: Locale) { + LOCALE.with(|value| value.set(language)); } -pub fn is_japanese() -> bool { - LANGUAGE.with(|value| value.get() == Language::Ja) -} - -fn japanese() -> &'static BTreeMap { - static MESSAGES: OnceLock> = OnceLock::new(); - MESSAGES.get_or_init(|| { - serde_json::from_str(include_str!("../locales/ja.json")).unwrap_or_else(|error| { - log::error!("Invalid Japanese message catalog: {error}"); - BTreeMap::new() - }) - }) +pub fn language() -> Locale { + LOCALE.with(std::cell::Cell::get) } /// Translate a built-in display label, preserving unknown labels verbatim. /// Never call this on editable user text, filenames or command identifiers. pub fn tr(source: &str) -> &str { - if is_japanese() { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source } + tr_in(language(), source) +} + +/// Catalog values are `'static` (they live in the embedded file), so a translation can be handed +/// out for as long as the `'static` key it was looked up by. +fn tr_in(language: Locale, source: &str) -> &str { + if language.catalog().is_empty() { + return source; + } + thread_local! { + static VERBATIM: RefCell>> = const { RefCell::new(BTreeMap::new()) }; + } + VERBATIM.with_borrow_mut(|cache| { + let verbatim = cache.entry(language).or_insert_with(|| catalog_verbatim(language)); + verbatim.get(source).copied().unwrap_or(source) + }) +} + +/// One language's catalog keyed for lookup, with values that outlive the catalog's own borrow. +fn catalog_verbatim(language: Locale) -> BTreeMap { + language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() +} + +// `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from +// `locales/*-formats.json`. +include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); + +/// 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)] @@ -71,53 +276,239 @@ mod tests { use super::*; #[test] - fn catalog_is_valid_and_contains_core_workflows() { - let messages: BTreeMap = serde_json::from_str(include_str!("../locales/ja.json")).unwrap(); - for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] { - assert!(messages.get(key).is_some_and(|value| !value.is_empty() && value != key), "{key}"); + fn every_language_has_a_catalog_covering_the_source() { + assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); + for language in Locale::ALL { + assert!(!language.name().is_empty(), "{language:?} has a name"); + assert!(!language.script().is_empty(), "{language:?} has a script"); + assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code"); + if *language == Locale::En { + continue; + } + let messages = language.catalog(); + assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code()); + for (key, value) in messages { + assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}"); + } } } + /// 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 language_switches_and_unknown_text_survives() { - set_language(Language::Ja); - assert_eq!(tr("Exposure"), "露出"); - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); - assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー"); - set_language(Language::En); - assert_eq!(tr("Exposure"), "Exposure"); + fn catalogs_agree_on_placeholders_and_report_gaps() { + let fields = |text: &str| { + let mut out: Vec = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find('{') { + let Some(end) = rest[start..].find('}') else { break }; + out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string()); + rest = &rest[start + end + 1..]; + } + out.sort(); + out + }; + let translated: Vec = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect(); + let 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:?}"); + } + 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()); + } + } + } + + /// Locale tags are matched leniently, so a system locale reaches a shipped language. + #[test] + fn locale_tags_normalize() { + assert_eq!(Locale::parse_tag("en"), Some(Locale::En)); + assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En)); + assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh-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); } #[test] - fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() { - set_language(Language::Ja); - assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12枚"); + fn switching_language_translates_and_restores() { + set_language(Locale::ZhHans); + assert_eq!(tr("Exposure"), "曝光"); + assert_eq!(tr("Settings"), "设置"); + // Untranslated and data-like text passes through untouched. + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + assert_eq!(tr("A string no catalog has"), "A string no catalog has"); + set_language(Locale::Ja); + assert_eq!(tr("Exposure"), "露出"); + set_language(Locale::En); + assert_eq!(tr("Exposure"), "Exposure"); + } + + /// Format strings carry their values in every language, and a language without a translation + /// falls back to the English format rather than dropping the values. + #[test] + fn formats_render_in_every_language() { + // Every language's format string is checked by `format!` against the call site's values. + set_language(Locale::En); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); + set_language(Locale::ZhHans); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片"); + set_language(Locale::Ja); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました"); assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました"); - set_language(Language::En); - assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 photos"); + // Format specs survive translation: precision, sign, and values a language reorders. + for language in Locale::ALL { + set_language(*language); + let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); + assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); + let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); + assert!(merging.contains("42%"), "{language:?}: {merging}"); + let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); + assert!(delta.contains("+5"), "{language:?}: {delta}"); + let reading = tr_format!("Reading photos… {} of {}", 3, 10); + let (three, ten) = (reading.find('3'), reading.find("10")); + assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); + } + set_language(Locale::En); + assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); + } + + /// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation + /// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK + /// catalogs are skipped (the same degradation the Japanese UI has always had). + #[test] + fn catalog_characters_are_paintable() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + for language in Locale::ALL { + let messages = language.catalog(); + if messages.is_empty() { + continue; + } + // The faces this language's script needs; a language the craft-fonts input does not + // cover (a translation added ahead of its font) is reported, not failed. + let mut faces: Vec<&str> = + lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.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()); + continue; + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family.clone()); + let mut missing: Vec = messages + .values() + .flat_map(|message| message.chars()) + .filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch)) + .collect(); + missing.sort_unstable(); + missing.dedup(); + assert!( + missing.is_empty(), + "{} in {family:?} (faces: {faces:?}) lack glyphs: {}", + language.code(), + missing.iter().collect::() + ); + } + }); + } + } + + /// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a + /// translation must not carry a symbol no installed font has (egui's defaults cover the rest). + #[test] + fn interface_symbols_are_paintable() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + // ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07). + ctx.fonts_mut(|fonts| { + for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font"); + } + }); + } + + /// The language menu covers every language, and a language's own command selects it. + #[test] + fn language_commands_cover_every_language() { + let commands = [ + ("app.language.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 { + assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}"); + assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu"); + } + assert_eq!(crate::menus::language_from_command("app.language.klingon"), None); + assert_eq!(crate::menus::language_from_command("view.detail"), None); } #[test] fn preferences_round_trip_and_old_settings_remain_readable() { let old: crate::state::UiState = serde_json::from_str("{}").unwrap(); - assert_eq!(old.language, Language::En); - let settings = crate::state::UiState { language: Language::Ja, ..old }; + assert_eq!(old.language, Locale::En); + // Settings written before the language list grew still load. + let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap(); + assert_eq!(legacy.language, Locale::Ja); + let settings = crate::state::UiState { language: Locale::ZhHans, ..old }; let saved = serde_json::to_string(&settings).unwrap(); let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Language::Ja); + assert_eq!(restored.language, Locale::ZhHans); } #[test] - fn japanese_is_painted_and_both_font_weights_cover_the_catalog() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.language = Language::Ja; - app.ui.left_panel = true; - let mut text = String::new(); + fn catalogs_contain_core_workflows() { + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] { + let value = language.tr(key); + assert!(!value.is_empty() && value != key, "{language:?}: {key}"); + } + } + } + + /// User-named menu items (presets, albums, label sets) are never translated; built-in labels are. + #[test] + fn menu_labels_translate_but_user_names_survive() { + for language in Locale::ALL { + set_language(*language); + assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); + } + set_language(Locale::ZhHans); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "颜色"); + set_language(Locale::Ja); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー"); + set_language(Locale::En); + } + + /// The text the whole window paints over a few frames in `language`. + fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String { fn collect(shape: &egui::epaint::Shape, text: &mut String) { match shape { egui::epaint::Shape::Text(shape) => { @@ -128,41 +519,49 @@ mod tests { _ => {} } } + app.ui.language = language; + let mut text = String::new(); for frame in 0..4 { let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); let mut out = ctx.run_ui(input, |ui| { app.logic(ui.ctx()); app.ui(ui); }); - // This assertion inspects shapes without a renderer; discard texture uploads explicitly. + // This inspects shapes without a renderer; discard texture uploads explicitly. out.textures_delta.clear(); text.clear(); for shape in out.shapes { collect(&shape.shape, &mut text); } } - assert!(text.contains("マイフォト"), "{text}"); - assert!(text.contains("すべての写真"), "{text}"); - if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() { - eprintln!("skipped glyph coverage: built without CRAFT_FONTS_DIR, so there is no Japanese UI font"); - } else { - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for message in japanese().values() { - for ch in message.chars().filter(|ch| !ch.is_whitespace()) { - assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}"); - } - } - } - }); - } - // Locale affects presentation only: command ids remain the same. - let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); - let japanese_ids = ids(&app); - set_language(Language::En); - assert_eq!(japanese_ids, ids(&app)); + text } + + /// Every language is painted by the real window, switching at runtime reinstalls the fonts + /// (their CJK fallback order follows the language), and command ids never change with it. + #[test] + fn every_language_is_painted_and_menu_ids_stay_the_same() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); + let english_ids = ids(&app); + for language in Locale::ALL { + let text = painted_text(&ctx, &mut app, *language); + for label in ["My Photos", "All Photos"] { + assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label)); + } + assert_eq!(app.font_language, *language, "the fonts follow the language"); + assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); + } + let text = painted_text(&ctx, &mut app, Locale::Ja); + assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}"); + let text = painted_text(&ctx, &mut app, Locale::ZhHans); + assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}"); + set_language(Locale::En); + } + /// The UI families, after one frame so the font definitions are loaded. fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { let ctx = egui::Context::default(); @@ -172,31 +571,36 @@ mod tests { ctx } - fn ui_families() -> [egui::FontFamily; 3] { - [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] - } - - /// Built with craft-fonts, Japanese renders with real glyphs (no tofu) in every UI family. + /// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI + /// family, Monospace included. #[test] - fn craft_fonts_render_japanese_in_the_ui() { - if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() { - eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese UI font"); - return; - } - let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); - ctx.fonts_mut(|fonts| { - for family in ui_families() { - let font = egui::FontId::new(13.0, family); - for ch in "日本語の文字".chars() { - assert!(fonts.has_glyph(&font, ch), "{ch} in {font:?}"); - } - let galley = fonts.layout_no_wrap("日本語の文字".into(), font.clone(), egui::Color32::WHITE); - assert!(galley.size().x > 13.0 * 5.0, "{font:?}: six full-width glyphs, {:?}", galley.size()); + fn craft_fonts_render_cjk_in_the_ui() { + let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; + for (language, sample) in samples { + 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; } - }); + set_language(language); + let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); + ctx.fonts_mut(|fonts| { + for family in + [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] + { + let font = egui::FontId::new(13.0, family); + for ch in sample.chars() { + assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}"); + } + let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE); + let wide = 13.0 * (sample.chars().count() as f32 - 1.0); + assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size()); + } + }); + } + set_language(Locale::En); } - /// Built without craft-fonts, the UI (in Japanese, too) still installs its fonts and runs; + /// Built without craft-fonts, the UI (in every language) still installs its fonts and runs; /// Latin text keeps Inter. #[test] fn the_ui_works_without_craft_fonts() { @@ -210,15 +614,117 @@ mod tests { } }); let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.language = Language::Ja; - for frame in 0..3 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); + for language in Locale::ALL { + app.ui.language = *language; + for frame in 0..3 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } } - set_language(Language::En); + set_language(Locale::En); + } + + /// 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/icons.rs b/crates/ui-egui/src/icons.rs index 58c90c241..b4afd8c1b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -78,6 +78,8 @@ pub enum Icon { /// Community chat (opens the ArtCraft Discord): a speech bubble with three dots. Our own /// generic drawing, not any service's logo. Chat, + /// Face boxes on/off: four corner brackets round a small face. + FaceBox, } struct Pen<'a> { @@ -404,6 +406,14 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.arc(10.0, 18.0, 6.5, 200.0, 340.0); pen.rect(2.5, 2.5, 17.5, 17.5, 2.0); } + FaceBox => { + pen.line(&[(2.5, 7.0), (2.5, 2.5), (7.0, 2.5)]); + pen.line(&[(13.0, 2.5), (17.5, 2.5), (17.5, 7.0)]); + pen.line(&[(2.5, 13.0), (2.5, 17.5), (7.0, 17.5)]); + pen.line(&[(13.0, 17.5), (17.5, 17.5), (17.5, 13.0)]); + pen.circle(10.0, 8.5, 2.4); + pen.arc(10.0, 16.0, 4.5, 215.0, 325.0); + } Picker => { pen.line(&[(4.0, 16.0), (11.5, 8.5)]); pen.closed(&[(11.0, 5.0), (15.0, 9.0), (17.0, 7.0), (13.0, 3.0)]); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 7e520bb38..3ac7c9eb1 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -105,6 +105,8 @@ pub struct ImportTask { params: Value, pub imported: usize, pub duplicates: usize, + /// The library photos the skipped duplicates match (shown when nothing new was added). + existing: Vec, pub failed: usize, /// Move: originals moved, and sources left in place (reported by the engine with a reason). pub moved: usize, @@ -563,6 +565,7 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc let len = |k: &str| v[k].as_array().map_or(0, Vec::len); task.imported += len("imported"); task.duplicates += len("duplicates"); + task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64())); task.failed += len("failed"); task.moved += len("moved"); task.kept += len("kept"); @@ -601,6 +604,11 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { let _ = app.run("library.select", json!({"ids": [f]})); } let plural = |n: usize| if n == 1 { "" } else { "s" }; + // nothing new, only files the library already has: show where they are (Recently Deleted is + // easy to miss, and the side panel that lists it starts collapsed) + if task.imported == 0 && task.failed == 0 && !task.cancelled && show_existing(app, ctx, &task.existing) { + return; + } let mut msg = if task.cancelled { crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) } else { @@ -614,6 +622,10 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } if task.duplicates > 0 { msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); + let deleted = deleted_of(app, &task.existing).len(); + if deleted > 0 { + msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); + } } if task.failed > 0 { msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); @@ -621,6 +633,43 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { app.toast(ctx, msg); } +/// The photos among `ids` that are in Recently Deleted (each once). +fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec { + let mut v: Vec = + ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect(); + v.sort_unstable(); + v.dedup(); + v +} + +/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel +/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none. +fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool { + let deleted = deleted_of(app, existing); + let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) }; + ids.sort_unstable(); + ids.dedup(); + ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some()); + if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() { + return false; + } + let _ = app.run("library.select", json!({"ids": ids})); + app.ui.left_panel = true; + let n = ids.len(); + let plural = if n == 1 { "" } else { "s" }; + let msg = if kind == "all" { + crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural) + } else { + crate::i18n::tr_format!( + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh", + n, + plural + ) + }; + app.toast_for(ctx, msg, 6.0); + true +} + /// The progress window while an import runs, with Cancel (files already copied or added stay; /// nothing new is started). pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { @@ -949,14 +998,16 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE)); p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE)); } + let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)); let badge = match (&c.duplicate, &c.error) { + (Some(_), _) if in_trash => Some("In Recently Deleted"), (Some(r), _) if r == "path" => Some("In library"), (Some(_), _) => Some("Duplicate"), (None, Some(_)) => Some("Unreadable"), _ => None, }; if let Some(b) = badge { - let g = p.layout_no_wrap(b.to_string(), t.semibold(10.0), Color32::WHITE); + let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); let br = Rect::from_min_size(pos2(img.right() - g.size().x - 12.0, img.top() + 6.0), g.size() + vec2(8.0, 4.0)); p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8addc61b5..bdf6f4f40 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1,7 +1,7 @@ //! LightCraft's egui frontend: a Lightroom-style UI over `lightcraft-engine`. //! //! The UI is thin: every action goes through [`LightcraftApp::run`], which handles UI commands -//! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine. +//! (views, panels, zoom — see [`menus::ui_commands`]) and forwards everything else to the engine. //! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]). #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -157,6 +157,9 @@ pub struct LightcraftApp { /// Clear `synthetic_mods` on the next frame. synthetic_mods_release: bool, styled: bool, + /// The language the installed fonts were built for: the CJK fallback order follows the UI + /// language's script, so switching language reinstalls them. + font_language: i18n::Locale, fonts_ready: bool, last_time: f64, /// Rect of the photo canvas and the displayed image (screen points) from the last frame. @@ -223,6 +226,7 @@ impl LightcraftApp { synthetic_mods: egui::Modifiers::NONE, synthetic_mods_release: false, styled: false, + font_language: i18n::Locale::En, fonts_ready: false, last_time: 0.0, canvas_rect: None, @@ -344,8 +348,13 @@ impl LightcraftApp { } pub fn toast(&mut self, ctx: &egui::Context, text: impl Into) { + self.toast_for(ctx, text, 1.4); + } + + /// A toast that stays `secs` seconds (messages that say where to look or what to do next). + pub fn toast_for(&mut self, ctx: &egui::Context, text: impl Into, secs: f64) { let t = ctx.input(|i| i.time); - self.ui.toast = Some((text.into(), t + 1.4)); + self.ui.toast = Some((text.into(), t + secs)); } fn drain_control(&mut self, ctx: &egui::Context) { @@ -497,6 +506,12 @@ impl LightcraftApp { let repaint = ctx.clone(); self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint())); self.styled = true; + self.font_language = self.ui.language; + } else if self.font_language != self.ui.language { + // Shared Han characters take the active language's forms (Japanese faces for 日本語, + // the Simplified Chinese face for 简体中文): rebuild the fallback order. + theme::install_fonts(ctx); + self.font_language = self.ui.language; } else { self.fonts_ready = true; } @@ -713,6 +728,7 @@ impl LightcraftApp { state::ViewMode::Compare => panels::compare::show_compare(self, ui), state::ViewMode::Survey => panels::compare::show_survey(self, ui), state::ViewMode::Reference => panels::compare::show_reference(self, ui), + state::ViewMode::People => panels::people::show(self, ui), }); panels::second::show(self, &ctx); panels::notices::show(self, &ctx); @@ -789,6 +805,7 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, + people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc>)>, suggestions: Option<(u64, std::sync::Arc>)>, counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc>)>, @@ -835,6 +852,23 @@ impl Caches { } } } + /// The people named on faces among the photos the filter lets through (its own `person` aside), + /// with photo counts. + pub fn people( + &mut self, + cat: &lightcraft_catalog::Catalog, + filter: &lightcraft_catalog::Filter, + ) -> std::sync::Arc> { + let key = lightcraft_catalog::Filter { person: None, ..filter.clone() }; + match &self.people { + Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(), + _ => { + let t = std::sync::Arc::new(cat.people_in(&key)); + self.people = Some((cat.revision, key, t.clone())); + t + } + } + } /// All Photos / Picks / Recently Deleted counts. pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts { if let Some((r, c)) = self.counts diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e0..7e8054f27 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -1,5 +1,5 @@ //! The menu bar model: File, Edit, View, Photo, Window, Help, generated from the command registry -//! (engine commands with menu paths + [`crate::menus::UI_COMMANDS`]) with live labels, shortcuts, +//! (engine commands with menu paths + [`crate::menus::ui_commands`]) with live labels, shortcuts, //! enabled and checked state. //! //! One model drives every menu surface: the native macOS menu bar (built by the desktop host), the @@ -119,9 +119,11 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.detail", "view.compare", "view.survey", + "view.people", "---", "view.leftPanel", "view.photoCounts", + "view.faceBoxes", "view.filmstrip", "view.histogram", "view.navigator", @@ -186,12 +188,15 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "photo.saveMetadataToFile", "photo.readMetadataFromFile", + "photo.reload", "app.showInFinder", "dialog.rename", "dialog.captureTime", "photo.tagFromTracklog", "---", "photo.delete", + "photo.restore", + "photo.deletePermanently", ], ), ( @@ -252,11 +257,20 @@ const HIDDEN: &[&str] = &[ "preset.create", ]; -/// Items only some hosts have are left out of the others' menus (the web build's library backup). +/// The selection includes a photo in Recently Deleted. +pub(crate) fn selection_deleted(app: &LightcraftApp) -> bool { + let s = &app.session; + s.selection.ids.iter().copied().chain(s.selection.active).any(|id| s.catalog.photo(id).is_some_and(|p| p.deleted)) +} + +/// Items only some hosts have are left out of the others' menus (the web build's library backup); +/// Restore and Delete Permanently replace Delete for photos in Recently Deleted. fn host_supports(app: &LightcraftApp, id: &str) -> bool { match id { "file.backupLibrary" => app.services.backup_library.is_some(), "file.restoreLibrary" => app.services.restore_library.is_some(), + "photo.restore" | "photo.deletePermanently" => selection_deleted(app), + "photo.delete" => !selection_deleted(app), _ => true, } } @@ -270,8 +284,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { let u = &app.ui; let panel = |p: RightPanel| Some(u.right == p); match id { - "app.language.english" => Some(u.language == crate::i18n::Language::En), - "app.language.japanese" => Some(u.language == crate::i18n::Language::Ja), + // Every language's command is checked when it is the active one. + _ if crate::menus::language_from_command(id).is_some() => Some(crate::menus::language_from_command(id) == Some(u.language)), "develop.autoSync" => Some(app.session.auto_sync), "view.photoCounts" => Some(u.show_counts), "view.secondWindow" => Some(u.second_window), @@ -280,8 +294,10 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.detail" => Some(u.view == ViewMode::Detail), "view.compare" => Some(u.view == ViewMode::Compare), "view.survey" => Some(u.view == ViewMode::Survey), + "view.people" => Some(u.view == ViewMode::People), "view.reference" => Some(u.view == ViewMode::Reference), "view.leftPanel" => Some(u.left_panel), + "view.faceBoxes" => Some(u.face_boxes), "view.filmstrip" => Some(u.filmstrip), "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), @@ -828,8 +844,9 @@ mod tests { let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect(); assert_eq!(titles, MENUS); let all: Vec = bar.iter().flat_map(|(_, v)| v.clone()).collect(); - // every engine command with a menu path is reachable (parameterized ones via submenus) - for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id)) { + // every engine command with a menu path is reachable (parameterized ones via submenus; items + // that follow the state, like Restore for deleted photos, when it applies) + for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id) && host_supports(&app, c.id)) { assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id); } for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5d838b759..5fc2b9bcb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -10,9 +10,34 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; /// (id, label, shortcut, menu path) pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str); +/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language +/// added to the table shows up in the menu (and in the control channel's command list) by itself. +pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ + ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), + ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), + ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), + ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), +]; + +/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from +/// this file, so an id listed in `docs/parity.md` is checked wherever it is declared. +pub fn ui_commands() -> impl Iterator { + LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS) +} + +/// The language a Language-menu command selects, if the id is one. The engine and the UI both go +/// through here, so the menu, the settings row and the control channel agree on the mapping. +pub fn language_from_command(id: &str) -> Option { + match id { + "app.language.english" => Some(crate::i18n::Locale::En), + "app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans), + "app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant), + "app.language.japanese" => Some(crate::i18n::Locale::Ja), + _ => None, + } +} + pub const UI_COMMANDS: &[UiCommand] = &[ - ("app.language.english", "English", None, "Edit>Language"), - ("app.language.japanese", "日本語", None, "Edit>Language"), ("view.photoGrid", "Photo Grid", None, "View"), ("view.squareGrid", "Square Grid", None, "View"), // G: Photo Grid ↔ Square Grid (from other views: the photo grid) @@ -21,6 +46,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.detail", "Detail", Some("D"), "View"), ("view.compare", "Compare", Some("Shift+C"), "View"), ("view.survey", "Survey", Some("N"), "View"), + ("view.people", "People", None, "View"), + ("view.faceBoxes", "Face Boxes", None, "View"), ("view.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), ("compare.swap", "Swap Compare Photos", None, "View"), @@ -206,8 +233,11 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { - if matches!(id, "app.language.english" | "app.language.japanese") { - app.ui.language = if id == "app.language.japanese" { crate::i18n::Language::Ja } else { crate::i18n::Language::En }; + if let Some(language) = language_from_command(id) { + app.ui.language = language; + // 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(); @@ -263,6 +293,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::enter_compare(app), + "view.faceBoxes" => { + app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); + Ok(json!({"show": app.ui.face_boxes})) + } + "view.people" => { + app.ui.view = ViewMode::People; + Ok(json!({"people": app.session.catalog.people().len()})) + } "view.survey" => { app.ui.view = ViewMode::Survey; Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()})) @@ -1251,8 +1289,7 @@ pub struct MenuEntry { /// The flattened menu model (UI commands + engine commands with menu paths). pub fn menu_entries(app: &LightcraftApp) -> Vec { - let mut v: Vec = UI_COMMANDS - .iter() + let mut v: Vec = ui_commands() .filter(|c| !c.3.is_empty()) .map(|(id, label, sc, m)| MenuEntry { id: id.to_string(), diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 4e0f91763..e36cba6a5 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -26,6 +26,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), + ("people", Icon::Subject, ViewMode::People, "People"), ] { if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { let _ = app.run(&format!("view.{id}"), json!({})); @@ -165,6 +166,11 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { { let _ = app.run("view.beforeAfter", json!({})); } + if app.ui.view == ViewMode::Detail + && icon_button(&mut child, "faceBoxes", Icon::FaceBox, vec2(30.0, 30.0), app.ui.face_boxes, true, "Face boxes").clicked() + { + let _ = app.run("view.faceBoxes", json!({})); + } if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() { let _ = app.run("view.filmstrip", json!({})); } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0697b75cc..cde3efdb1 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -51,6 +51,7 @@ pub enum Gesture { mask: u32, comp: usize, handle: u8, + original_shape: MaskShape, }, /// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates). Guide { @@ -333,6 +334,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let map = CanvasMap::new(&frame, img_rect); let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); info_overlay(app, &p, canvas, &photo); + if !fullscreen { + filter_pill(app, ui, canvas); + } + if app.ui.face_boxes { + match region_overlay(ui, &p, &map, &photo, d.orientation) { + Some(RegionEdit::Remove(index)) => { + let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); + } + Some(RegionEdit::Resize(index, r)) => { + let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}})); + } + None => {} + } + } // a fine grid while a transform (geometry) slider is dragged, to judge verticals if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { let n = 12; @@ -420,6 +435,163 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); } +/// What the user did to a face box this frame. +enum RegionEdit { + /// The × was clicked. + Remove(usize), + /// A handle drag ended: the region's new box (normalized, upright frame). + Resize(usize, lightcraft_geom::Rect), +} + +/// Handles of a box: (x, y) as fractions of its width and height, and the cursor they show. +const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ + (0.0, 0.0, egui::CursorIcon::ResizeNwSe), + (0.5, 0.0, egui::CursorIcon::ResizeVertical), + (1.0, 0.0, egui::CursorIcon::ResizeNeSw), + (1.0, 0.5, egui::CursorIcon::ResizeHorizontal), + (1.0, 1.0, egui::CursorIcon::ResizeNwSe), + (0.5, 1.0, egui::CursorIcon::ResizeVertical), + (0.0, 1.0, egui::CursorIcon::ResizeNeSw), + (0.0, 0.5, egui::CursorIcon::ResizeHorizontal), +]; + +/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows +/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once, +/// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: +/// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo; +/// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes. +fn region_overlay( + ui: &egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + photo: &lightcraft_catalog::Photo, + orient: lightcraft_geom::Orientation, +) -> Option { + let t = Tokens::get(p.ctx()); + let clip = p.clip_rect(); + let pointer = ui.input(|i| i.pointer.hover_pos()); + // the box being dragged: (region index, its box so far) + let drag_key = egui::Id::new("region-drag"); + let live: Option<(usize, lightcraft_geom::Rect)> = ui.data(|d| d.get_temp(drag_key)); + let mut edit = None; + for (index, r) in photo.meta.regions.iter().enumerate() { + let dragging = live.filter(|(i, _)| *i == index); + let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n)); + let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1))); + // white with a black keyline just outside it, so the box shows on any background + p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside); + p.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Outside); + if dragging.is_some() || (live.is_none() && pointer.is_some_and(|h| rect.expand(8.0).contains(h))) { + for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() { + let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height()); + let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::new(("region-handle", index, h)), Sense::drag()); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(*cursor); + } + let sq = Rect::from_center_size(c, vec2(7.0, 7.0)); + p.rect_filled(sq, 0.0, if resp.hovered() || resp.dragged() { Color32::from_rgb(120, 190, 255) } else { Color32::WHITE }); + p.rect_stroke(sq, 0.0, Stroke::new(1.0, Color32::from_black_alpha(220)), StrokeKind::Outside); + if resp.dragged() + && let Some(pp) = resp.interact_pointer_pos() + { + // move the dragged edge(s) to the pointer, never closer than 12 px to the opposite one + let mut n = rect; + if *fx == 0.0 { + n.min.x = pp.x.min(n.max.x - 12.0); + } else if *fx == 1.0 { + n.max.x = pp.x.max(n.min.x + 12.0); + } + if *fy == 0.0 { + n.min.y = pp.y.min(n.max.y - 12.0); + } else if *fy == 1.0 { + n.max.y = pp.y.max(n.min.y + 12.0); + } + let (a, b) = (map.norm(n.min), map.norm(n.max)); + let shown = lightcraft_geom::Rect { + x0: a.x.min(b.x).clamp(0.0, 1.0), + y0: a.y.min(b.y).clamp(0.0, 1.0), + x1: a.x.max(b.x).clamp(0.0, 1.0), + y1: a.y.max(b.y).clamp(0.0, 1.0), + }; + // back to the upright frame the region is stored in + let new = orient.inverse().map_norm_rect(shown); + ui.data_mut(|d| d.insert_temp(drag_key, (index, new))); + ui.ctx().request_repaint(); + } + if resp.drag_stopped() { + if let Some((i, new)) = ui.data(|d| d.get_temp::<(usize, lightcraft_geom::Rect)>(drag_key)) { + edit = Some(RegionEdit::Resize(i, new)); + } + ui.data_mut(|d| d.remove_temp::<(usize, lightcraft_geom::Rect)>(drag_key)); + } + } + } + if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { + let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0)); + let resp = ui.interact(xr, egui::Id::new(("region-x", index)), Sense::click()); + register(ui.ctx(), format!("regionRemove:{index}"), xr); + let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 }); + p.rect_filled(xr, 2.0, fill); + p.rect_stroke(xr, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside); + let (c, m) = (xr.center(), 3.5); + let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) }); + p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); + p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); + if resp.on_hover_text("Remove this face box (undo with Edit ▸ Undo)").clicked() { + edit = Some(RegionEdit::Remove(index)); + } + } + let Some(name) = &r.name else { continue }; + // the name in a dark label with a caret, centred above the box (below it when there is no room) + let g = p.layout_no_wrap(name.clone(), t.font(13.0), Color32::from_gray(225)); + let (pad, caret) = (vec2(14.0, 7.0), 5.0); + let size = g.size() + pad * 2.0; + let above = rect.top() - caret - size.y >= clip.top(); + let (top, tip, base) = if above { + (rect.top() - caret - size.y, rect.top() - 1.0, rect.top() - caret - 1.0) + } else { + (rect.bottom() + caret, rect.bottom() + 1.0, rect.bottom() + caret + 1.0) + }; + let left = (rect.center().x - size.x / 2.0).clamp(clip.left(), (clip.right() - size.x).max(clip.left())); + let label = Rect::from_min_size(pos2(left, top), size); + let fill = Color32::from_rgba_unmultiplied(56, 56, 56, 235); + p.rect_filled(label, 3.0, fill); + p.rect_stroke(label, 3.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Inside); + let cx = rect.center().x.clamp(label.left() + caret + 4.0, label.right() - caret - 4.0); + p.add(egui::Shape::convex_polygon(vec![pos2(cx - caret, base), pos2(cx + caret, base), pos2(cx, tip)], fill, Stroke::NONE)); + p.galley(label.min + pad, g, Color32::from_gray(225)); + } + edit +} + +/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous +/// follow them, so a filter must never be invisible here. A click clears them all. It sits in the +/// canvas margin, so the photo does not move. +fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { + let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); + if chips.is_empty() { + return; + } + let t = Tokens::get(ui.ctx()); + let p = ui.painter_at(canvas); + let label = chips.iter().map(|c| c.label.as_str()).collect::>().join(" · "); + let g = p.layout_no_wrap(format!("Filtered: {label}"), t.font(12.0), Color32::from_gray(225)); + let w = (g.size().x + 40.0).min((canvas.width() - 24.0).max(60.0)); + let r = Rect::from_min_size(canvas.min + vec2(12.0, 3.0), vec2(w, 20.0)); + let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click()); + register(ui.ctx(), "loupe:filters", r); + p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 })); + let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y)); + p.with_clip_rect(text_clip).galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225)); + let (c, m) = (pos2(r.right() - 13.0, r.center().y), 3.0); + let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) }); + p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross); + p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross); + if resp.on_hover_text("Click to clear these filters").clicked() { + let _ = app.run("library.clearFilter", json!({})); + } +} + /// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so; /// hovering it explains (registered as `notice:previewOnly:loupe`). fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) { @@ -867,10 +1039,49 @@ pub(crate) fn component_pin(shape: &MaskShape) -> Option { } /// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. -fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point) -> MaskShape { +fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape { let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); match shape.clone() { - MaskShape::Radial { center, rx, ry, angle, feather, invert } => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, + MaskShape::Radial { center, rx, ry, angle, feather, invert } => match handle { + 1 | 3 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let new_rx = (dx * co + dy * s).abs().max(0.01); + let mut new_ry = ry; + if alt { + new_ry = ry * (new_rx / rx.max(0.01)); + } + if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { + new_ry = new_rx; + } + MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } + } + 2 | 4 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let (s, co) = angle.to_radians().sin_cos(); + let new_ry = (dx * (-s) + dy * co).abs().max(0.01); + let mut new_rx = rx; + if alt { + new_rx = rx * (new_ry / ry.max(0.01)); + } + if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 { + new_rx = new_ry; + } + MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert } + } + 5 => { + let l = frame_long_norm(map); + let dx = (at.x - center.x) / l.0; + let dy = (at.y - center.y) / l.1; + let new_angle = dy.atan2(dx).to_degrees(); + MaskShape::Radial { center, rx, ry, angle: new_angle, feather, invert } + } + _ => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, + }, MaskShape::Linear { start, end } => match handle { 1 => MaskShape::Linear { start: at, end }, 2 => MaskShape::Linear { start, end: at }, @@ -915,6 +1126,26 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons }) .collect(); p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230)))); + + let l = frame_long_norm(map); + for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() { + let a_local = a_deg.to_radians(); + let (s, co) = angle.to_radians().sin_cos(); + let (x, y) = (rx * a_local.cos(), ry * a_local.sin()); + let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)); + p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui.ctx(), format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((m.id, ci, i as u8 + 1, q)); + } + + let (s, co) = angle.to_radians().sin_cos(); + let rot_r = rx + 0.08; + let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1)); + let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1)); + p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200))); + p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE)); + register(ui.ctx(), format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0))); + grips.push((m.id, ci, 5, rot_q)); } MaskShape::Linear { start, end } if sel => { let (a, b) = (map.screen(*start), map.screen(*end)); @@ -1003,21 +1234,25 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let m = d.masks.iter().find(|m| Some(m.id) == active)?; matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) }); - if let Some((mask, comp, handle)) = grip { + if let Some((mask, comp, handle)) = grip + && let Some(original_shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone()) + { if Some(mask) != active { let _ = app.run("mask.select", json!({"id": mask})); } let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); - app.gesture = Some(Gesture::MaskHandle { mask, comp, handle }); + app.gesture = Some(Gesture::MaskHandle { mask, comp, handle, original_shape }); } } if resp.dragged() - && let (Some(Gesture::MaskHandle { mask, comp, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) - && let Some(shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| &c.shape) + && let (Some(Gesture::MaskHandle { mask, comp, handle, original_shape }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) { let n = map.norm(q); - let n0 = map.norm(q - resp.drag_delta()); - let new_shape = moved_shape(shape, handle, Point::new(n.x - n0.x, n.y - n0.y), n); + let press_origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(q); + let n0 = map.norm(press_origin); + let dn = Point::new(n.x - n0.x, n.y - n0.y); + let alt = ui.input(|i| i.modifiers.alt); + let new_shape = moved_shape(&original_shape, handle, dn, n, map, alt); let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape})); } if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) { @@ -1300,6 +1535,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { }; let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode})); } + // the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted) + resp.context_menu(|ui| super::grid::context_menu(app, ui, *id)); } }); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fe8b04781..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); } @@ -733,7 +733,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Shortcuts => { egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { egui::Grid::new("shortcuts").striped(true).show(ui, |ui| { - for (id, label, sc, _) in crate::menus::UI_COMMANDS { + for (id, label, sc, _) in crate::menus::ui_commands() { if let Some(sc) = sc { ui.label(crate::i18n::tr(label)); ui.label(*sc); @@ -750,8 +750,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } for (sc, id, _) in crate::shortcuts::ALIASES { - let label = crate::menus::UI_COMMANDS - .iter() + let label = crate::menus::ui_commands() .find(|c| c.0 == *id) .map(|c| c.1) .or_else(|| lightcraft_engine::find_command(id).map(|c| c.label)) @@ -776,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 760abbfe0..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})); } } @@ -821,6 +825,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Rename…")).clicked() { let _ = app.run("dialog.rename", json!({})); } + if ui.button(crate::i18n::tr("Reload from Disk")).clicked() { + let _ = app.run("photo.reload", json!({})); + } if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() { let _ = app.run("photo.virtualCopy", json!({})); } @@ -905,7 +912,14 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.separator(); - if ui.button(crate::i18n::tr("Delete Photo")).clicked() { + if crate::menubar::selection_deleted(app) { + if ui.button(crate::i18n::tr("Restore")).clicked() { + let _ = app.run("photo.restore", json!({})); + } + if ui.button(crate::i18n::tr("Delete Permanently")).clicked() { + let _ = app.run("photo.deletePermanently", json!({})); + } + } else if ui.button(crate::i18n::tr("Delete Photo")).clicked() { let _ = app.run("photo.delete", json!({})); } } 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/mod.rs b/crates/ui-egui/src/panels/mod.rs index cfb40d68a..8317dda78 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -13,6 +13,7 @@ pub mod left; pub mod library_problem; pub mod masking; pub mod notices; +pub mod people; pub mod presets; pub mod profiles; pub mod right; diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs new file mode 100644 index 000000000..118fbd1c3 --- /dev/null +++ b/crates/ui-egui/src/panels/people.rs @@ -0,0 +1,87 @@ +//! People: a card per person named on faces (read from XMP): a close-up of their largest face, the +//! name and how many photos they are in. A click shows that person's photos in the grid. +//! +//! Only the rows on screen ask for a face render (the engine caches them, memory and disk). + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use lightcraft_catalog::Person; +use serde_json::json; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::register; + +/// A card's face picture (points); the name and count sit below it. +const CARD: f32 = 150.0; +const LABEL_H: f32 = 44.0; +const GAP: f32 = 16.0; +const PAD: f32 = 20.0; +const HEADER_H: f32 = 44.0; + +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let people = app.caches.people(&app.session.catalog, &app.session.filter); + let (head, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER_H), Sense::hover()); + ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, "Named People", t.semibold(15.0), t.text); + ui.painter().text(pos2(head.right() - PAD, head.center().y), Align2::RIGHT_CENTER, people.len().to_string(), t.font(13.0), t.text_dim); + // the filters narrowing the list (a date, a keyword…), removable here + let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); + super::chips::show(app, ui, &chips); + if people.is_empty() { + let (title, body) = if chips.is_empty() { + ("No named people yet", "Face names written to XMP by Lightroom and other apps show up here.") + } else { + ("No named people in these photos", "Remove a filter above, or choose Clear all") + }; + super::empty_message(ui, ui.available_rect_before_wrap(), title, body); + return; + } + let ppp = ui.ctx().pixels_per_point(); + let active = app.session.filter.person.clone(); + egui::ScrollArea::vertical().auto_shrink(false).show_viewport(ui, |ui, viewport| { + let width = ui.available_width(); + let cols = (((width - PAD * 2.0 + GAP) / (CARD + GAP)).floor() as usize).max(1); + let row_h = CARD + LABEL_H + GAP; + let rows = people.len().div_ceil(cols); + let (area, _) = ui.allocate_exact_size(vec2(width, PAD * 2.0 + rows as f32 * row_h), Sense::hover()); + let first = ((viewport.top() - PAD) / row_h).floor().max(0.0) as usize; + let last = (((viewport.bottom() - PAD) / row_h).ceil().max(0.0) as usize).min(rows); + for row in first..last { + for col in 0..cols { + let Some(person) = people.get(row * cols + col) else { break }; + let min = area.min + vec2(PAD + col as f32 * (CARD + GAP), PAD + row as f32 * row_h); + let selected = active.as_deref().is_some_and(|a| a.eq_ignore_ascii_case(&person.name)); + card(app, ui, person, Rect::from_min_size(min, vec2(CARD, CARD + LABEL_H)), ppp, selected); + } + } + }); +} + +fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ppp: f32, selected: bool) { + let t = Tokens::get(ui.ctx()); + let face = Rect::from_min_size(r.min, vec2(CARD, CARD)); + let resp = ui.interact(r, egui::Id::new(("person-card", &person.name)), Sense::click()); + register(ui.ctx(), format!("person:{}", person.name), r); + let p = ui.painter(); + p.rect_filled(face, 3.0, t.canvas); + if let Some(job) = app.session.face_job(person.photo, person.face, (CARD * ppp).ceil() as usize) + && let Some(tex) = app.renderer.variant(job) + { + p.image(tex.tex.id(), face, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + if selected { + p.rect_stroke(face, 3.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); + } else if resp.hovered() { + p.rect_stroke(face, 3.0, Stroke::new(1.0, t.text_dim), StrokeKind::Outside); + } + // a long name must not run past the card + let name = + if person.name.chars().count() > 19 { format!("{}…", person.name.chars().take(18).collect::()) } else { person.name.clone() }; + p.text(pos2(r.left() + 2.0, face.bottom() + 14.0), Align2::LEFT_CENTER, name, t.semibold(13.0), t.text); + let photos = if person.count == 1 { "1 photo".to_string() } else { format!("{} photos", person.count) }; + p.text(pos2(r.left() + 2.0, face.bottom() + 32.0), Align2::LEFT_CENTER, photos, t.font(12.0), t.text_dim); + if resp.on_hover_text(format!("{} — show their photos", person.name)).clicked() { + let _ = app.run("library.filter", json!({"person": person.name})); + let _ = app.run("view.photoGrid", json!({})); + } +} 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/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index a380090e1..e191668d9 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -91,7 +91,8 @@ fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Language"), |ui| { - choices(ui, "settingsLanguage", &[(crate::i18n::Language::Ja, "日本語"), (crate::i18n::Language::En, "English")], &mut app.ui.language); + let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect(); + choices(ui, "settingsLanguage", &languages, &mut app.ui.language); crate::i18n::set_language(app.ui.language); }); heading(ui, t, crate::i18n::tr("Library")); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 07665dad0..c5d2ce16a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -89,7 +89,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); ctx.input(|i| { let mut ui_keys = Vec::new(); - for (id, _, sc, _) in crate::menus::UI_COMMANDS { + for (id, _, sc, _) in crate::menus::ui_commands() { if let Some((m, k)) = sc.and_then(parse) { ui_keys.push((m, k)); if !native(sc.unwrap_or_default()) && matches(i, m, k) { @@ -286,7 +286,7 @@ mod tests { assert_eq!(parse("Right").unwrap().1, Key::ArrowRight); assert_eq!(parse("\\").unwrap().1, Key::Backslash); // every declared shortcut parses - for (id, _, sc, _) in crate::menus::UI_COMMANDS { + for (id, _, sc, _) in crate::menus::ui_commands() { if let Some(sc) = sc { assert!(parse(sc).is_some(), "{id}: {sc}"); } @@ -300,7 +300,7 @@ mod tests { #[test] fn aliases_parse_and_target_existing_commands() { - let ui: Vec<&str> = crate::menus::UI_COMMANDS.iter().map(|c| c.0).collect(); + let ui: Vec<&str> = crate::menus::ui_commands().map(|c| c.0).collect(); for (sc, id, params) in ALIASES { assert!(parse(sc).is_some(), "{id}: {sc}"); assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}"); @@ -315,7 +315,7 @@ mod tests { #[test] fn no_conflicting_bindings() { let mut ui: Vec<((Modifiers, Key), String)> = Vec::new(); - for (id, _, sc, _) in crate::menus::UI_COMMANDS { + for (id, _, sc, _) in crate::menus::ui_commands() { if let Some(k) = sc.and_then(parse) { ui.push((k, id.to_string())); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1a4446e3a..151c33245 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -16,6 +16,8 @@ pub enum ViewMode { Survey, /// A reference photo (left, fixed) beside the active photo (right, being edited). Reference, + /// A card per person named on faces (close-up, name, photo count). + People, } /// The right-hand tool/panel shown next to the tool strip. @@ -201,7 +203,7 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0; #[serde(default, rename_all = "camelCase")] pub struct UiState { #[serde(default = "crate::i18n::default_language")] - pub language: crate::i18n::Language, + pub language: crate::i18n::Locale, /// The Build Previews run last announced (its identity, finished?). #[serde(skip)] pub preview_build_seen: Option<(usize, bool)>, @@ -262,6 +264,8 @@ pub struct UiState { pub grid_info: String, /// Photo counts next to sources and albums in the left panel. pub show_counts: bool, + /// Face / pet boxes (read from XMP) over the photo in the loupe. + pub face_boxes: bool, /// Local sidebar locations hidden with “Remove from Local” (folders on disk are untouched). pub hidden_locations: Vec, /// Copies opened in an external editor this session (reloaded when the window is focused @@ -548,6 +552,7 @@ impl Default for UiState { show_filenames: true, grid_info: "filename".into(), show_counts: true, + face_boxes: true, hidden_locations: Vec::new(), dragging_control: None, external_edits: Vec::new(), diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b5db692d5..c77fb0a2f 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -110,31 +110,35 @@ pub fn install_fonts(ctx: &egui::Context) { ctx.set_fonts(font_definitions(lightcraft_engine::CRAFT_FONTS)); } -/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts Japanese faces as -/// the last fallback of every family (BIZ UDPGothic first; Bold first for the semibold family). -/// Without craft-fonts (`craft` empty) Japanese text has no glyphs and shows as boxes. +/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts CJK faces as the last +/// fallback of every family — the active language's own script first, so shared Han characters keep +/// that language's forms. Without craft-fonts (`craft` empty) CJK text has no glyphs and shows as +/// boxes. pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontDefinitions { let mut fonts = FontDefinitions::default(); fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")))); - let japanese: Vec<_> = lightcraft_engine::fonts::japanese(craft).collect(); - for f in &japanese { - fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes))); - } // Craft-fonts faces in preference order for a family drawn in `style`. let fallback = |style: &str| { - let mut faces = japanese.clone(); - faces.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != style, f.family.contains("Mincho"))); - faces.into_iter().map(craft_font_name).collect::>() + lightcraft_engine::fonts::cjk_fallback(craft, crate::i18n::language().script(), style) + .into_iter() + .map(craft_font_name) + .collect::>() }; + let (regular, bold) = (fallback("Regular"), fallback("Bold")); + for name in regular.iter().chain(bold.iter()) { + if let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) { + fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes))); + } + } let defaults: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); let mut prop = vec!["Inter".to_string()]; prop.extend(defaults.iter().cloned()); - prop.extend(fallback("Regular")); + prop.extend(regular); fonts.families.insert(FontFamily::Proportional, prop); let mut semi = vec!["Inter-SemiBold".to_string()]; semi.extend(defaults); - semi.extend(fallback("Bold")); + semi.extend(bold); fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi); fonts.families.entry(FontFamily::Monospace).or_default().extend(fallback("Regular")); fonts diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index c996e6cd3..f1c6924d1 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -1,6 +1,6 @@ -# Sony ARW starting look +# Sony ARW and Nikon NEF starting look -ARW decoding supplies a Bayer mosaic and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The ARW loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW mosaic; there is no JPEG replacement or uniform saturation boost. +ARW and NEF decoding supply a Bayer mosaic and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW mosaic; there is no JPEG replacement or uniform saturation boost. ## Colour and tone are separate @@ -32,9 +32,15 @@ Five local Sony ILCE-6700 ARWs were accepted. Held-out per-channel linear-displa Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, -7M4 ×2, -7RM4, -7SM3, DSC-RX100M3), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 7 fits accepted, ΔE 17–26 → 3–10 with matching mean lightness. (The RX100M3 was rejected at the time because the decoder read neither its white balance nor its black level and its render was green; since issue #148 its fit is accepted, held-out RMS 0.153 → 0.087.) Rejected and unchanged: an ILCE-7RM4 dusk scene whose fit was worse than the fallback (ΔE 7.6), and a reduced-size lossless ARW (no fit attempted). Saturated hues can still differ (an orange-red hull renders yellow). With the hue/saturation table, 28 local ILCE-7M4 ARWs (one month of photos, one in 24 picked) went from mean ΔE 4.74 to 4.27 against their camera JPEGs (200 px, CIE76); 27 accepted a table, none got worse overall by more than 0.1, and a few saturated regions moved away (12.4 → 15.2 on one). The lime shirt's hue error dropped from 16° to 6–8°. With the value axis, the chroma curve and gamma-domain selection, the same set plus the shirt photo (29 files) went from mean ΔE 4.23 to 3.54 (4.74 before the table), every file closer than before, saturated areas 7.65 → 6.41; all 29 now use a table. A small object stays hard: in a second shot of the lime shirt only 7 of the 6144 proxy pixels show it, so the table can't learn its colour and the shirt still renders yellower than the JPEG. +### Nikon NEF + +The same fit, gates and relative WB apply to NEF/NRW (issue #150: NEFs rendered muted and greenish). Public CC0 samples from raw.pixls.us (D750 12/14-bit, D780 12-bit, D850 12/14-bit, D7500 12/14-bit lossless and lossy, Z 50 12-bit) plus five local D7500 shots of an indoor event, rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): all 13 decodable files accepted, held-out RMS 0.11–0.24 → 0.01–0.09, ΔE 13–46 → 2.9–7.8 with matching mean lightness. The exception is a mixed-light D7500 scene (faces lit by a purple screen): accepted (RMS 0.156 → 0.057) but only ΔE 12.7 → 12.0, rendered warmer and lighter than the camera JPEG; the linear-display error the gate measures is dominated by the bright screen. Files that still open as the embedded preview (lossy-after-split, uncompressed data labelled compressed) are not fitted. + +Measuring this found a decoder bug: 12-bit NEFs store maker note `0x003d` BlackLevel in 14-bit units (D750 600, D780/Z 50 1008, D850/D7500 400, while their darkest samples are 150 / ~252 / 99), so 12-bit files rendered nearly black or with crushed shadows before any fit. The decoder now scales it to the sample depth; the fits for the same D7500 scene at 12 and 14 bits then agree (held-out RMS 0.042–0.049 vs 0.050–0.054; before, the 12-bit fits had to desaturate to compensate). + On one sample, Exposure +1 EV, Saturation +100, Contrast +50, warm/cool WB, tint, highlights and shadows all changed the output. The same matrix and curve were selected for 400-pixel and full-resolution renders. Headless UI checks wait until the loupe reports `source: render` and no jobs remain; the camera JPEG stand-in does not count as verification. -This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour. +This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony and Nikon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour. ## Camera profiles (pooled per model) diff --git a/docs/localization-ja.md b/docs/localization-ja.md index 4d1aa6277..bcfb8a8dd 100644 --- a/docs/localization-ja.md +++ b/docs/localization-ja.md @@ -1,9 +1,9 @@ # LightCraft 日本語表示 -LightCraft は日本語と英語(English)の表示に対応しています。 +LightCraft は日本語・簡体字中国語・英語(English)の表示に対応しています。 言語設定は `ui.json` の `language` に保存します。 -- 「編集 → 言語」または「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。 +- 「編集 → 言語」または「設定 → 一般 → 言語」で切り替えます。選択は次回起動にも引き継ぎます。 - メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。 - 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを使います。日本語フォントはこのリポジトリではなく [storytold/craft-fonts](https://github.com/storytold/craft-fonts) にあり、任意のビルド入力 @@ -11,12 +11,16 @@ LightCraft は日本語と英語(English)の表示に対応しています 指定しない場合も動作しますが、日本語の文字は表示されません。 - 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。 - 翻訳されていない技術的なエラー、追加情報、リリースノートは英語で表示します。 +- 中国語表示は [`localization-zh-hans.md`](localization-zh-hans.md) を参照してください。 +- 別の言語の追加方法は [`localization.md`](localization.md) を参照してください。 ## 翻訳の保守 静的な表示文言は `crates/ui-egui/locales/ja.json`、可変値を含む表示文言は `crates/ui-egui/locales/ja-formats.json` にあります。英文をキーにして翻訳を管理します。 -可変文言の両言語はビルド時にRustのフォーマット検査を受けます。 + +可変文言の訳文はすべてビルド時にRustの `format!` 検査を受けます。値は名前(`{n}`)または +位置(`{0}`、`{1}`)で参照し、`{:.1}` などの書式指定は英文と同じものを使います。 英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。 `LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。 diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md new file mode 100644 index 000000000..002232b11 --- /dev/null +++ b/docs/localization-zh-hans.md @@ -0,0 +1,36 @@ +# LightCraft 简体中文界面 + +LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 `ui.json` 的 `language` 字段中。 + +- 通过「编辑 → 语言」或「设置 → 一般 → 语言」切换,选择会保留到下次启动。 +- 已翻译的范围:菜单、照片编辑、蒙版、裁剪、设置、导入、导出,以及主要的进度提示。 +- 正文字重与半粗体都使用 SIL OFL 的 **Noto Sans CJK SC**(简体中文)与 BIZ UDPGothic(日语)。 + 这些字体不在本仓库,而在 [storytold/craft-fonts](https://github.com/storytold/craft-fonts); + 使用可选的构建输入 `CRAFT_FONTS_DIR=../craft-fonts` 时(所有正式发布版)会内嵌进来。 + 不指定该输入时程序照常构建运行,但中文与日文文字没有字形,会显示为方框。 +- 翻译只发生在显示层:命令 ID、照片文件名、用户输入的元数据都不做改动。 +- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。 +- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框 + (单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。 + 原生桌面版不受影响。 +- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。 + +## 翻译的维护 + +静态界面文字在 `crates/ui-egui/locales/zh-hans.json`,含运行时数值的文字在 +`crates/ui-egui/locales/zh-hans-formats.json`。两者都以英文原文为键。 + +含数值的文字遵循以下约定: + +- 每个译文都是普通的 Rust 格式字符串,构建时由 `format!` 对照调用点的参数检查(占位符写错会编译失败)。 +- **按名字**:`{n}`、`{label}`、`{total}` 等与英文原文中的名字一致。 +- **按位置**:`{0}`、`{1}` … 对应调用点无名参数的先后顺序(从 0 开始),用于调整语序, + 例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{1}”{0:.0}`。 +- 保留英文原文的格式说明(`{:.1}`、`{d:+}`、`{:.0}%`)。 +- `format!` 不允许未使用的参数:英文用来表示单复数的那个参数(`"s"` / `""`)在中文里写成 + `{:.0}`(精度为 0 的字符串不输出任何内容)。 + +`LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。 + +翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由 +`cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。 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 new file mode 100644 index 000000000..f6db12ffe --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,86 @@ +# 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`), 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. 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"); + } + ``` + + The fields are the BCP-47 code (also the settings-file value), the **endonym** shown in the + Language menu, the ISO 15924 script (`Latn`, `Jpan`, `Hans`, `Hant`, `Kore`…), and the embedded + catalog. `build.rs` picks up `-formats.json` by name, so the macro and the format lookup + follow automatically — nothing else in the UI needs to change. The menus, the settings row, the + keyboard-shortcut sheet and the control channel all read the table. +3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a + language whose script has no face in the build shows boxes (the test says so instead of failing). + Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a + missing face is added there, not worked around in this repository. +4. Run `cargo xtask ci` (it includes `fmt`, `clippy`, the tests, `parity` and the wasm check). + +## How a message is found + +`tr("Exposure")` looks the English label up in the active language's catalog and returns the source +text unchanged when there is no entry; `tr_format!` handles text with runtime values. Both are +presentation-only: command ids, file names and user-entered metadata are never translated. + +`tr_format!` is generated by `crates/ui-egui/build.rs`: one arm per English message, which formats +the active language's translation with `format!` (English is the fallback arm). Every catalog's +format string is therefore checked by Rust against the call site's own arguments: + +```rust +tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album, n = n) +``` + +- A translation is an ordinary Rust format string over the same arguments. Refer to named values by + name (`{n}`) and to positional ones by index when the word order changes (`{0}`, `{1}`): + `"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`). +- `format!` rejects an unused argument, so a value a language does not need — the English plural + suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing). +- Every formats catalog carries the same messages (`build.rs` fails the build otherwise), and a + `tr_format!` call whose message is in no catalog does not compile: add it to every + `*-formats.json` file. + +## Testing and looking at it + +```sh +cargo test -p lightcraft-ui-egui i18n::tests # catalogs, keys, placeholders, commands +CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage +``` + +The glyph tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then). +To look at a language, render it headless: + +```sh +LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot --demo --script tour.jsonl -o out.png --size 1600x1000 +``` + +or, with the app running (`--control 7980`), run the language command +(`engine.execute {"command": "app.language.simplifiedChinese"}`) and take a screenshot. + +## Language codes and settings + +The settings file stores the BCP-47 code, never the Rust variant name, so a language can be renamed +in code without invalidating anyone's saved preference. `LIGHTCRAFT_LANGUAGE` accepts what a system +locale looks like (`zh_Hans`, `zh-CN`, `en_US`, `ja_JP.UTF-8`) and falls back to the base language +when a region has no dedicated entry. diff --git a/docs/parity.md b/docs/parity.md index 678d22329..38d768af5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -22,9 +22,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 11 | 3 | 1 | 1 | 3/5 (60%) | 5/5 (100%) | -| B. Library management (LIB) | 22 | 1 | 2 | 2 | 9/9 (100%) | 9/9 (100%) | +| B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | | C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | -| D. Search & filter (FILT) | 11 | 0 | 2 | 0 | 4/4 (100%) | 4/4 (100%) | +| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | | F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) | @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 15 | 3 | 2 | 1 | 8/8 (100%) | 6/8 (75%) | +| X. Cross-cutting behaviours (BEHAV) | 16 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | -| Lightroom Classic extras | 31 | 9 | 40 | 9 | — | 21/22 (95%) | -| **Total** | 387 | 29 | 91 | 37 | 194/200 (97%) | 139/149 (93%) | +| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) | +| **Total** | 388 | 33 | 87 | 37 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.2% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.6%** of 508 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 40.9% of 159. ## Top gaps @@ -59,7 +59,7 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)). Take the first one nobody is working on. -1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW now gets a guarded file-local fit to its own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. +1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW and Nikon NEF now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate`; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. 2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, RW2 v4, Nikon "lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see `crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`. @@ -122,7 +122,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, RW2 v4, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -145,10 +145,10 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | -| LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | -| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" | +| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; no confirmation dialog, no auto-purge after N days, no "Empty" | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | @@ -169,7 +169,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put | | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar; a click on the photo (and Z / Space) eases to the click-zoom ratio (1:1 default, 2:1, 3:1, 4:1, 8:1 in the bottom bar) | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session | @@ -195,7 +195,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker | | LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers | | LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) | -| LR-FILT-PEOPLE | People filter | P2 | ⬜ | | | +| LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip or `person:`; no unnamed / suggested people | | LR-FILT-CULL | Culling-score filters | P2 | ✅ | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums | | LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order | | LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library | @@ -274,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a local per-model profile pooled from many photos (`lightcraft-cli calibrate`, `crates/engine/src/camera_profiles.rs`), the profile's colour and only its own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW and Nikon NEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a local per-model profile pooled from many photos (`lightcraft-cli calibrate`, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -503,7 +503,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization-ja.md` | Japanese/English UI with persisted language preference and Japanese regular/bold fonts; menus, edit, crop/masks, settings, import/export and primary progress messages. Technical errors/release notes and other locales remain untranslated. | +| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.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 | ⬜ | | | @@ -600,7 +601,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | | | MENU-HELP-FEEDBACK | Send feedback | P2 | ✅ | `cmd:app.feedback` | opens a new issue on the project's GitHub | | MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) | -| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete | +| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | | MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules | | MENU-CTX-MASK | Mask / component menu | P0 | ✅ | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`, `component_row_menu`), `cmd:mask.component` | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) | @@ -724,7 +725,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ✅ | `crates/catalog/src/keywords.rs`, `cmd:keyword.list`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops) | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | -| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | | +| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `crates/ui-egui/src/panels/people.rs` | Named People cards (face close-up, name, photos) for the photos the active filters let through, with the filter chips shown above them; no Unnamed People, no per-person page, no confirm / reject | | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | diff --git a/docs/whats-new.md b/docs/whats-new.md index 3045deadf..12984b9a0 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -83,6 +83,16 @@ changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel. ### Library +- Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the + Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no + longer just says "duplicate skipped": it opens the side panel on Recently Deleted with the photo selected and says + how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits, + Cmd+Shift+R, keeps the photo and clears its edits.) +- Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the + 1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw + data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in + the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they + were missing, without touching anything already set. - Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets `img_1-1.JPG`; on case-insensitive volumes the case change still goes through. @@ -124,6 +134,10 @@ - Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions. - Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map. - ⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming. +- Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted, + greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs + (e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was + read in the wrong units. ### Viewing and sharing - Slideshow, second window, All Metadata, System Info. diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 017f35691..003b8774d 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -70,6 +70,26 @@ library share a stem, the stem sidecar belongs to one of them — a raw first, o the others write and read `.xmp` (`IMG_0001.JPG.xmp`), falling back to the stem sidecar for reading when they have none. So saving one photo never overwrites the other's metadata (`Session::sidecar_naming`). +## Face regions (MWG) + +LightCraft reads face, pet, focus and barcode regions in the Metadata Working Group's region schema +(`mwg-rs:Regions`, `http://www.metadataworkinggroup.com/schemas/regions/`), as Lightroom, digiKam, Picasa and others +write them, from sidecars and from XMP embedded in the file. Named faces become people in the People view +(LR-LIB-PEOPLE in [`parity.md`](parity.md)), and every region is drawn as a box in the loupe. + +- **Read only.** LightCraft never writes `mwg-rs:Regions`; saving a sidecar keeps another application's regions byte + for byte. Removing or resizing a box in the loupe changes the library only. +- **Areas**: `stArea:x`/`y` are the box's centre, `w`/`h` its size, normalized to the photo (`stArea:unit="pixel"` + areas are divided by `mwg-rs:AppliedToDimensions`, and dropped without it). Boxes are clipped to the photo; one + that misses it entirely, or has a non-finite, zero or negative size, is skipped (the rest of the list is kept). +- **Orientation**: regions are stored on the upright (EXIF-oriented) photo. A `mwg-rs:Rotation` of −π/2, +π/2 or ±π + (how Lightroom marks a box given in the sensor's frame for Exif orientation 6, 8 or 3) is turned back into the + upright frame; MWG can't say "mirrored", so boxes on mirrored photos (orientations 2, 4, 5, 7) are taken as written. + Boxes follow LightCraft's own Rotate Left/Right and flips. +- **Re-reading**: a sidecar that has `mwg-rs:Regions` — even with an empty list — replaces the photo's regions, so + regions removed in another application go away here too. A sidecar without `mwg-rs:Regions` (an application that + doesn't do regions) leaves the photo's regions as they are. + ## Reading `crs:` develop fields Many raw developers store edits as `crs:` properties (`http://ns.adobe.com/camera-raw-settings/1.0/`) in sidecars, in diff --git a/xtask/src/main.rs b/xtask/src/main.rs index fe4e1948b..3cfcd3b8a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -281,6 +281,14 @@ const RAW_SAMPLES: &[(&str, &str)] = &[ "nef-nikon-d7000-lossy12.nef", "https://raw.pixls.us/getfile.php/961/nice/Nikon%20-%20D7000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", ), + ( + "nef-nikon-d7500-lossless12.nef", + "https://raw.pixls.us/getfile.php/1532/nice/Nikon%20-%20D7500%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + ), + ( + "nef-nikon-d7500-lossless14.nef", + "https://raw.pixls.us/getfile.php/1534/nice/Nikon%20-%20D7500%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + ), ( "nrw-nikon-b700-uncompressed.nrw", "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index caa3932a9..fc87ecc38 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -137,12 +137,17 @@ pub fn parse(md: &str) -> Doc { doc } -/// Ids in `UI_COMMANDS` (`("view.detail", "Detail", Some("D"), "View"),` lines). +/// Ids in the menu tables (`("view.detail", "Detail", Some("D"), "View"),` lines): the language +/// commands live in their own table, which the Language menu builds from the i18n language list. pub fn ui_command_ids(menus_rs: &str) -> Vec { - let Some(start) = menus_rs.find("pub const UI_COMMANDS") else { return Vec::new() }; - let body = &menus_rs[start..]; - let body = &body[..body.find("];").unwrap_or(body.len())]; - body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())).collect() + let mut ids = Vec::new(); + for table in ["pub const UI_COMMANDS", "pub const LANGUAGE_COMMANDS"] { + let Some(start) = menus_rs.find(table) else { continue }; + let body = &menus_rs[start..]; + let body = &body[..body.find("];").unwrap_or(body.len())]; + ids.extend(body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string()))); + } + ids } fn known(set: &BTreeSet, id: &str) -> bool { @@ -437,5 +442,19 @@ old fn reads_ui_command_ids() { let src = "pub const UI_COMMANDS: &[UiCommand] = &[\n (\"view.detail\", \"Detail\", Some(\"D\"), \"View\"),\n (\"app.about\", \"About\", None, \"\"),\n];\nfn x() { (\"not.this\", 1); }"; assert_eq!(ui_command_ids(src), ["view.detail", "app.about"]); + let languages = + "pub const LANGUAGE_COMMANDS: &[UiCommand] = &[\n (\"app.language.english\", Locale::En.name(), None, \"Edit>Language\"),\n];\n"; + assert_eq!(ui_command_ids(&format!("{languages}{src}")), ["view.detail", "app.about", "app.language.english"]); + } + + /// The real menu tables: both are found, so every language command is a known id. + #[test] + fn reads_the_language_commands_from_menus_rs() { + let menus = include_str!("../../crates/ui-egui/src/menus.rs"); + let ids = ui_command_ids(menus); + for id in ["view.detail", "app.language.english", "app.language.japanese", "app.language.simplifiedChinese"] { + assert!(ids.iter().any(|known| known == id), "{id} not found"); + } + assert_eq!(ids.iter().filter(|id| id.as_str() == "app.language.english").count(), 1, "one Language menu entry per language"); } }