From 2c94b7fa1f4de14522993110ad4286bb952ce2b4 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Mon, 5 Oct 2026 23:51:43 -0700 Subject: [PATCH 01/30] Export: protect originals imported through differently named symlinks Co-Authored-By: OpenAI Codex --- crates/engine/src/originals.rs | 11 +++++++++++ crates/engine/src/tests_export.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index 59a5f6a20..d20d2fe8e 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -58,6 +58,17 @@ impl OriginalGuard { } } } + // An imported symlink can have a different name from its original. A write to + // that original would change what the library reads through the link. Only do + // this fallback for existing targets that the name index did not protect. + for (p, what) in self.by_name.values().flatten() { + if std::fs::read_link(p).is_ok() && same_file(p, target) { + 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/tests_export.rs b/crates/engine/src/tests_export.rs index dcd74cb4b..422601338 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -323,3 +323,29 @@ fn a_failed_export_write_keeps_the_previous_file() { assert_ne!(std::fs::read(&prev).unwrap(), b"last week's export"); 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); +} From 6ad215e5b93337368d6230fcc32de1ec6172e211 Mon Sep 17 00:00:00 2001 From: Yu Zhi Chen Date: Wed, 7 Oct 2026 04:21:14 +0800 Subject: [PATCH 02/30] feat(ui): improve radial mask resizing and rotation - Added 4 control handles on the edge of the radial gradient mask to resize rx and ry independently. - Added a rotation handle to adjust the mask angle. - Changed drag gesture handling to store the original mask shape to prevent drift from accumulated relative deltas. - Holding the Option/Alt key while dragging a handle scales the mask proportionally. - Snaps the mask to a perfect circle (1:1 aspect ratio) when the aspect ratio is within 5%. --- crates/ui-egui/src/panels/detail.rs | 80 ++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0697b75cc..700c03f6c 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 { @@ -867,10 +868,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 +955,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 +1063,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 { .. })) { From f29436a1f6710361ca76d0ac22c964b6edd786bd Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:23:27 -0400 Subject: [PATCH 03/30] Meta: read MWG-RS face/pet/focus regions from XMP (read-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Metadata::regions: Vec, populated by parse_xmp from mwg-rs:Regions (MWG Region Guidelines v2.0) — the format Lightroom, digiKam and Picasa write for face/pet/focus/barcode regions. Read-only: LightCraft does not write regions yet (blocked on #92, which would otherwise clobber existing sidecars). - Canonicalises the mwg-rs/stArea/stDim namespaces so any prefix a writer picks resolves consistently, reusing the existing generic struct/array XMP machinery (handles both the element and attribute encodings transparently). - Area center-coordinates are converted to lightcraft_geom::Rect via Rect::from_center. Normalized units (the default, and the only unit we've seen in practice) need no AppliedToDimensions cross-reference; the rare pixel-unit region is converted when AppliedToDimensions is present, and dropped (not stored as a nonsense fraction) when it isn't. - Hostile/malformed regions (missing Area, NaN/absurd/zero/negative extents) are skipped individually rather than failing the whole list or panicking. - regions merges like keywords in fill_missing/overlay_user_fields, since it only ever comes from XMP today. Not included: nothing yet reads Metadata::regions (no catalog field, no UI, no command) — this is plumbing for a later PR, with no user-visible effect and no docs/parity.md row to update. cargo xtask ci passes (fmt, clippy -D warnings, test, parity, layers, assets, wasm). Co-Authored-By: Claude Sonnet 5 --- crates/meta/src/lib.rs | 35 +++++++- crates/meta/src/xmp.rs | 184 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 217 insertions(+), 2 deletions(-) diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 3408aed76..467a89af6 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -24,7 +24,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 +48,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 +138,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 +200,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 +215,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..225cb0489 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,52 @@ 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, + }; + 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: Rect::from_center(Point::new(cx, cy), w, h), kind, name, description }) +} + fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String { let a = v.abs(); let d = a.trunc(); @@ -470,6 +522,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 +663,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 +820,130 @@ 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")); + } + + /// 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); + } + + /// `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]); + } } From f1b2d9af11b8e2a714fdbfb666bc32ede185e6ae Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:31:12 -0400 Subject: [PATCH 04/30] Catalog/UI: show XMP face/pet regions in the loupe view Threads the Vec that lightcraft-meta already reads from XMP (MWG-RS) through to the screen, so it's no longer inert: - catalog::Meta gains a `regions` field (catalog now depends on lightcraft-meta, which doesn't depend back on catalog, so no cycle). - crates/engine/src/files.rs (meta_of) and src/sidecar.rs (parse_sidecar / merge_into) populate it for both embedded-XMP and external-sidecar photos, mirroring how `keywords` is already handled. - ui-egui's loupe view (panels/detail.rs) draws each region as a rectangle over the photo via the existing CanvasMap, color-coded by kind (Face/Pet/other) with its name label if present, right alongside the existing info overlay. Read-only, no new interaction. New end-to-end test (regions_from_sidecar_are_read_on_import) covers a region written to a sidecar reaching Photo.meta.regions through real import, not just the parser. cargo xtask ci green (fmt, clippy -D warnings, test, parity, layers, assets, wasm). Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 2 ++ crates/catalog/Cargo.toml | 1 + crates/catalog/src/model.rs | 3 ++ crates/engine/src/files.rs | 1 + crates/engine/src/sidecar.rs | 5 ++++ crates/engine/src/tests_xmp.rs | 45 +++++++++++++++++++++++++++++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/panels/detail.rs | 23 +++++++++++++++ 8 files changed, 81 insertions(+) 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/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/model.rs b/crates/catalog/src/model.rs index 8b400bb52..763d30486 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -183,6 +183,9 @@ pub struct Meta { pub copyright_url: String, pub creator: String, pub keywords: Vec, + /// Face/pet/focus regions read from XMP (MWG-RS). Read-only today; nothing in + /// LightCraft writes regions yet. + pub regions: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index f4a57a6f3..7ca1d1fdd 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())) } diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 69bf8f0f0..f57ac16a5 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,7 @@ 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_xmp.rs b/crates/engine/src/tests_xmp.rs index 0832e2fb0..5b2919d0b 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -173,6 +173,51 @@ 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); +} + fn synthetic_dng(xmp: &str) -> Vec { synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default()) } 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/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0697b75cc..d1f1f6bad 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -333,6 +333,7 @@ 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); + region_overlay(&p, &map, &photo); // 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 +421,28 @@ 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))); } +/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Read-only — +/// LightCraft doesn't write regions yet. +fn region_overlay(p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) { + use lightcraft_meta::RegionKind; + let t = Tokens::get(p.ctx()); + for r in &photo.meta.regions { + let color = match r.kind { + RegionKind::Face => Color32::from_rgb(255, 214, 10), + RegionKind::Pet => Color32::from_rgb(120, 220, 120), + RegionKind::Focus | RegionKind::BarCode | RegionKind::Other(_) => Color32::from_white_alpha(140), + }; + let rect = Rect::from_two_pos(map.screen(Point::new(r.rect.x0, r.rect.y0)), map.screen(Point::new(r.rect.x1, r.rect.y1))); + p.rect_stroke(rect, 2.0, Stroke::new(1.5, color), StrokeKind::Outside); + if let Some(name) = &r.name { + let g = p.layout_no_wrap(name.clone(), t.font(12.0), color); + let at = pos2(rect.left(), rect.top() - g.size().y - 2.0); + p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); + p.galley(at, g, color); + } + } +} + /// 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) { From 9715c90413298f0d16437921e0774fd790baef8c Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:52:43 -0400 Subject: [PATCH 05/30] Meta: map Lightroom's quarter-turn region Rotation to the upright frame Lightroom Classic stores the box of a photo with Exif orientation 6 / 8 in the sensor's own frame and records the orientation as mwg-rs:Rotation (-pi/2 for 6, +pi/2 for 8, 0 for 1) while AppliedToDimensions names the upright size. Ignoring Rotation put every portrait photo's face box in the wrong place. Convert such regions into the upright frame, with a synthetic regression test for -pi/2, +pi/2 and 0. Co-Authored-By: Claude Sonnet 5.5 --- crates/meta/src/xmp.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 225cb0489..f86876cae 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -414,6 +414,16 @@ fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option (cx / dw, cy / dh, w / dw, h / dh), (true, None) => return None, }; + // Lightroom Classic stores the box of a photo with Exif orientation 6 / 8 in the sensor's own frame + // and writes the orientation as the region's `mwg-rs:Rotation` (−π/2 for 6, +π/2 for 8; 0 for 1) + // while `AppliedToDimensions` names the upright size. Turn it into the upright frame. + let rotation = item.field("mwg-rs:Rotation").and_then(XmpValue::text).and_then(parse_number).filter(|r| r.is_finite()); + let quarter = std::f64::consts::FRAC_PI_2; + let (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), + _ => (cx, cy, w, h), + }; 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("") { @@ -873,6 +883,38 @@ mod tests { assert_eq!(regions[2].description.as_deref(), Some("Good boy")); } + /// Lightroom Classic writes a portrait photo's (Exif orientation 6 / 8) box in the sensor's frame and + /// records the orientation as `mwg-rs:Rotation` (−π/2 / +π/2); the reader returns the upright frame. + #[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(), 3, "{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); + } + /// 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. From f5f8bc5d3492c7074f47cad2c349998d46bda48c Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:52:43 -0400 Subject: [PATCH 06/30] UI: draw face labels as a dark caret label above a white/black keyline box The name sits in a dark rounded label centred above the box with a small caret (below it when there is no room above), replacing the offset-shadow text. The box is a 1px white outline with a 1px black keyline so it shows on any background. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/detail.rs | 36 ++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index d1f1f6bad..262f41bbb 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -424,22 +424,32 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li /// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Read-only — /// LightCraft doesn't write regions yet. fn region_overlay(p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) { - use lightcraft_meta::RegionKind; let t = Tokens::get(p.ctx()); + let clip = p.clip_rect(); for r in &photo.meta.regions { - let color = match r.kind { - RegionKind::Face => Color32::from_rgb(255, 214, 10), - RegionKind::Pet => Color32::from_rgb(120, 220, 120), - RegionKind::Focus | RegionKind::BarCode | RegionKind::Other(_) => Color32::from_white_alpha(140), - }; let rect = Rect::from_two_pos(map.screen(Point::new(r.rect.x0, r.rect.y0)), map.screen(Point::new(r.rect.x1, r.rect.y1))); - p.rect_stroke(rect, 2.0, Stroke::new(1.5, color), StrokeKind::Outside); - if let Some(name) = &r.name { - let g = p.layout_no_wrap(name.clone(), t.font(12.0), color); - let at = pos2(rect.left(), rect.top() - g.size().y - 2.0); - p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); - p.galley(at, g, color); - } + // 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); + 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)); } } From 7c27c3a920890833b48366291c63a72be4bf9173 Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:03:28 -0400 Subject: [PATCH 07/30] People MVP: list named faces from XMP in the sidebar and filter by person MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only and names-only: the left panel gets a People section built from the named Face regions read from XMP, with photo counts (once per photo, case insensitive, pets and unnamed faces left out). A click filters the grid via a new `person` field on `library.filter`, also reachable as a `person:` search token and shown as a filter chip. The list is cached on the catalog revision. No face detection, identity or writing regions back yet. Parity rows for the People list, filter and view are 🟡 with that scope noted. Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/query.rs | 47 +++++++++++++++++++++++++++++-- crates/catalog/src/tests.rs | 44 +++++++++++++++++++++++++++++ crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/view.rs | 4 ++- crates/ui-egui/src/lib.rs | 12 ++++++++ crates/ui-egui/src/panels/left.rs | 25 ++++++++++++++++ docs/parity.md | 18 ++++++------ 7 files changed, 138 insertions(+), 14 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 72c7ffb1b..b7165bb15 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,28 @@ impl Catalog { } m.into_iter().collect() } + + /// The people named on faces in the library (MWG regions read from XMP) with the number of + /// photos each appears in, 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<(String, usize)> { + let mut m: std::collections::HashMap = Default::default(); + for p in self.photos().filter(|p| p.in_library()) { + 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(); + if seen.contains(&key) { + continue; + } + m.entry(key.clone()).or_insert_with(|| (name.to_string(), 0)).1 += 1; + seen.push(key); + } + } + let mut v: Vec<(String, usize)> = m.into_values().collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))); + v + } } #[derive(Clone, Debug, PartialEq, Serialize)] diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 86442d2a9..9add629ef 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -297,3 +297,47 @@ 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 d = add("d.jpg", vec![region(Some("John Roe"), RegionKind::Face)]); + let e = add("e.jpg", vec![region(Some("Sam"), RegionKind::Face)]); + add("f.jpg", vec![]); + assert_eq!( + c.people(), + vec![("Jane Doe".to_string(), 2), ("John Roe".to_string(), 2), ("Sam".to_string(), 1)], + "once per photo, pets and unnamed faces left out" + ); + + 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)); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 74e5d5b0f..debe6f68c 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(); 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/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8addc61b5..449bf4df6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -789,6 +789,7 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, + people: Option<(u64, std::sync::Arc>)>, suggestions: Option<(u64, std::sync::Arc>)>, counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc>)>, @@ -835,6 +836,17 @@ impl Caches { } } } + /// The people named on faces, with photo counts. + pub fn people(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + match &self.people { + Some((r, t)) if *r == cat.revision => t.clone(), + _ => { + let t = std::sync::Arc::new(cat.people()); + self.people = Some((cat.revision, 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/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 7d7037435..12477757b 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -134,6 +134,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } } + people_section(app, ui); keywords_section(app, ui); ui.add_space(10.0); if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { @@ -664,6 +665,30 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig false } +/// "People": the names on faces read from XMP (MWG regions), with photo counts. A click filters the +/// grid to that person; a second click clears it. (Names only for now: no face detection or +/// identity yet, and LightCraft does not write regions.) +fn people_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let people = app.caches.people(&app.session.catalog); + if people.is_empty() { + return; + } + ui.add_space(10.0); + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); + ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "People", t.semibold(13.5), t.text_label); + for (name, count) in people.iter() { + let sel = app.session.filter.person.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(name)); + // a long name must not run into the count + let shown = if name.chars().count() > 22 { format!("{}…", name.chars().take(21).collect::()) } else { name.clone() }; + let resp = row(app, ui, &format!("person:{name}"), Icon::Subject, &shown, Some(*count), sel, 0.0).on_hover_text(name); + if resp.clicked() { + let v = if sel { serde_json::Value::Null } else { json!(name) }; + let _ = app.run("library.filter", json!({"person": v})); + } + } +} + /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click /// filters the grid by the keyword (children included), the triangle opens a level, and the /// context menu renames, merges or deletes the keyword across the library. diff --git a/docs/parity.md b/docs/parity.md index fc94d08a7..fd9f57ea3 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) | 15 | 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** | 387 | 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 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 40.5% of 158. ## Top gaps @@ -145,7 +145,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | -| LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:library.filter`, `crates/ui-egui/src/panels/left.rs` (`people_section`), `crates/catalog/src/query.rs` (`people`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only) are listed with photo counts in the left panel's People section; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip); no face detection, no identity/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" | @@ -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); 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 | @@ -724,7 +724,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:library.filter`, `crates/ui-egui/src/panels/left.rs` (`people_section`) | a People list in the sidebar (names and counts) that filters the grid; no face-cluster grid or per-person view yet | | 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 | From 8859f797d8f4e8a16c89364df1b0687c9b5aa053 Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:40:44 -0400 Subject: [PATCH 08/30] People view: a card per named person with a cropped face, replacing the sidebar list A dedicated view mode (View > People, and a toolbar button) shows a card per person named on faces: 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 cards on screen render their face. The sidebar section is gone (it would get crowded). `Catalog::people()` now returns `Person { name, count, photo, face }`, the photo and face being the person's largest. `Session::face_job` renders a square close-up through the engine (the photo's own look, crop replaced, the smallest source level that keeps it sharp, cached like variant thumbnails); it clamps hostile rectangles, with a test for corners, oversized, degenerate and non-finite faces. Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/lib.rs | 2 +- crates/catalog/src/query.rs | 40 ++++++++---- crates/catalog/src/tests.rs | 14 +++-- crates/engine/src/media.rs | 37 +++++++++++ crates/engine/src/tests.rs | 34 ++++++++++ crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/menubar.rs | 2 + crates/ui-egui/src/menus.rs | 5 ++ crates/ui-egui/src/panels/bottombar.rs | 1 + crates/ui-egui/src/panels/left.rs | 25 -------- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/people.rs | 87 ++++++++++++++++++++++++++ crates/ui-egui/src/state.rs | 2 + docs/parity.md | 6 +- 14 files changed, 213 insertions(+), 48 deletions(-) create mode 100644 crates/ui-egui/src/panels/people.rs 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/query.rs b/crates/catalog/src/query.rs index b7165bb15..e25e889a1 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -350,29 +350,47 @@ impl Catalog { m.into_iter().collect() } - /// The people named on faces in the library (MWG regions read from XMP) with the number of - /// photos each appears in, 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<(String, usize)> { - let mut m: std::collections::HashMap = Default::default(); + /// 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 { + let mut m: std::collections::HashMap = Default::default(); for p in self.photos().filter(|p| p.in_library()) { 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(); - if seen.contains(&key) { - continue; + // 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); } - m.entry(key.clone()).or_insert_with(|| (name.to_string(), 0)).1 += 1; - seen.push(key); } } - let mut v: Vec<(String, usize)> = m.into_values().collect(); - v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))); + 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)] pub struct DateGroup { pub year: String, diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 9add629ef..3c257da1a 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -323,14 +323,16 @@ fn people_from_named_face_regions() { ); let b = add("b.jpg", vec![region(Some("JANE DOE"), RegionKind::Face), region(Some("John Roe"), RegionKind::Face), region(None, RegionKind::Face)]); - let d = add("d.jpg", vec![region(Some("John Roe"), 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![]); - assert_eq!( - c.people(), - vec![("Jane Doe".to_string(), 2), ("John Roe".to_string(), 2), ("Sam".to_string(), 1)], - "once per photo, pets and unnamed faces left out" - ); + 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); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index c61470d0f..6a5e3d3dc 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -705,6 +705,43 @@ 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(); + let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + 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().str(&content_key(p)).str("view").u64(p.develop.hash64()).u64(apply_crop as u64).u64(RENDER_CACHE_VERSION).finish() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 58b7e643e..9becd6c62 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -727,3 +727,37 @@ 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()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 449bf4df6..d0c33e6a2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -713,6 +713,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,7 +790,7 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, - people: Option<(u64, std::sync::Arc>)>, + people: Option<(u64, std::sync::Arc>)>, suggestions: Option<(u64, std::sync::Arc>)>, counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc>)>, @@ -837,7 +838,7 @@ impl Caches { } } /// The people named on faces, with photo counts. - pub fn people(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + pub fn people(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { match &self.people { Some((r, t)) if *r == cat.revision => t.clone(), _ => { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e0..fce32157a 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -119,6 +119,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.detail", "view.compare", "view.survey", + "view.people", "---", "view.leftPanel", "view.photoCounts", @@ -280,6 +281,7 @@ 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.filmstrip" => Some(u.filmstrip), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5d838b759..a4a8ced9d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -21,6 +21,7 @@ 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.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), ("compare.swap", "Swap Compare Photos", None, "View"), @@ -263,6 +264,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::enter_compare(app), + "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()})) diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 4e0f91763..049a8fbd6 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!({})); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 12477757b..7d7037435 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -134,7 +134,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } } - people_section(app, ui); keywords_section(app, ui); ui.add_space(10.0); if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { @@ -665,30 +664,6 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig false } -/// "People": the names on faces read from XMP (MWG regions), with photo counts. A click filters the -/// grid to that person; a second click clears it. (Names only for now: no face detection or -/// identity yet, and LightCraft does not write regions.) -fn people_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let people = app.caches.people(&app.session.catalog); - if people.is_empty() { - return; - } - ui.add_space(10.0); - let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); - ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "People", t.semibold(13.5), t.text_label); - for (name, count) in people.iter() { - let sel = app.session.filter.person.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(name)); - // a long name must not run into the count - let shown = if name.chars().count() > 22 { format!("{}…", name.chars().take(21).collect::()) } else { name.clone() }; - let resp = row(app, ui, &format!("person:{name}"), Icon::Subject, &shown, Some(*count), sel, 0.0).on_hover_text(name); - if resp.clicked() { - let v = if sel { serde_json::Value::Null } else { json!(name) }; - let _ = app.run("library.filter", json!({"person": v})); - } - } -} - /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click /// filters the grid by the keyword (children included), the triangle opens a level, and the /// context menu renames, merges or deletes the keyword across the library. 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..52108c53e --- /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); + 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); + if people.is_empty() { + let c = ui.available_rect_before_wrap().center(); + ui.painter().text(c, Align2::CENTER_CENTER, "No named people yet", t.semibold(15.0), t.text_label); + ui.painter().text( + c + vec2(0.0, 26.0), + Align2::CENTER_CENTER, + "Face names written to XMP by Lightroom and other apps show up here.", + t.font(13.0), + t.text_dim, + ); + 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/state.rs b/crates/ui-egui/src/state.rs index 1a4446e3a..8e14cabb5 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. diff --git a/docs/parity.md b/docs/parity.md index fd9f57ea3..c73133b8f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -145,7 +145,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | -| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:library.filter`, `crates/ui-egui/src/panels/left.rs` (`people_section`), `crates/catalog/src/query.rs` (`people`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only) are listed with photo counts in the left panel's People section; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip); no face detection, no identity/suggestions, no manual tagging, no writing regions back | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). 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" | @@ -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 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only); no unnamed / suggested people | +| 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 | @@ -724,7 +724,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 | 🟡 | `cmd:library.filter`, `crates/ui-egui/src/panels/left.rs` (`people_section`) | a People list in the sidebar (names and counts) that filters the grid; no face-cluster grid or per-person view yet | +| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `crates/ui-egui/src/panels/people.rs` | Named People cards (face close-up, name, photos); 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 | From b9621eb5a3737be94bb2ce81f826a37c2df84abb Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:48:41 -0400 Subject: [PATCH 09/30] People view: offer only people in the photos the active filters let through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view listed everyone in the library, so with another filter active (a date, a keyword…) picking a person could end in an empty grid. The list now covers the photos matching every filter except `person` itself, counts are within them, and the filter chips show above the cards (removable, Clear all). `Catalog::people_in(&Filter)`; the cache is keyed on the catalog revision and the filter. Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/query.rs | 10 +++++++++- crates/catalog/src/tests.rs | 17 +++++++++++++++++ crates/ui-egui/src/lib.rs | 18 ++++++++++++------ crates/ui-egui/src/panels/people.rs | 20 ++++++++++---------- docs/parity.md | 2 +- 5 files changed, 49 insertions(+), 18 deletions(-) diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index e25e889a1..384c2ae55 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -355,8 +355,16 @@ impl Catalog { /// 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| p.in_library()) { + 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 }; diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 3c257da1a..7897f7972 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -342,4 +342,21 @@ fn people_from_named_face_regions() { 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"); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d0c33e6a2..d870e7188 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -790,7 +790,7 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, - people: 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>)>, @@ -837,13 +837,19 @@ impl Caches { } } } - /// The people named on faces, with photo counts. - pub fn people(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { + /// 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, t)) if *r == cat.revision => t.clone(), + Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(), _ => { - let t = std::sync::Arc::new(cat.people()); - self.people = Some((cat.revision, t.clone())); + let t = std::sync::Arc::new(cat.people_in(&key)); + self.people = Some((cat.revision, key, t.clone())); t } } diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 52108c53e..118fbd1c3 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -20,20 +20,20 @@ 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); + 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 c = ui.available_rect_before_wrap().center(); - ui.painter().text(c, Align2::CENTER_CENTER, "No named people yet", t.semibold(15.0), t.text_label); - ui.painter().text( - c + vec2(0.0, 26.0), - Align2::CENTER_CENTER, - "Face names written to XMP by Lightroom and other apps show up here.", - t.font(13.0), - t.text_dim, - ); + 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(); diff --git a/docs/parity.md b/docs/parity.md index c73133b8f..b29c5e069 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -724,7 +724,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 | 🟡 | `cmd:view.people`, `crates/ui-egui/src/panels/people.rs` | Named People cards (face close-up, name, photos); no Unnamed People, no per-person page, no confirm / reject | +| 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 | From e2e6db0f1449fb19be5c03963927348afbf40c41 Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:19:31 -0400 Subject: [PATCH 10/30] =?UTF-8?q?Faces:=20switch=20face=20boxes=20on/off?= =?UTF-8?q?=20and=20remove=20one=20with=20the=20=C3=97=20shown=20on=20hove?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View > Face Boxes (and a toolbar button in the loupe) hides or shows the boxes. Hovering a box shows a × in its corner; clicking it runs the new undoable `photo.removeRegion {id?, index}`, a catalog-only edit. The XMP sidecar is never rewritten for it, even with auto-write on (a `skip_auto_write` flag consumed when the command ends), because the sidecar writer builds a fresh packet that does not carry the regions: reading the metadata from the file brings a removed region back. A test with a real sidecar proves the file stays byte for byte, that ordinary edits still auto-write, and that undo restores it. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/library.rs | 26 ++++++++++++++++++++ crates/engine/src/lib.rs | 11 +++++++-- crates/engine/src/tests_xmp.rs | 30 +++++++++++++++++++++++ crates/ui-egui/src/icons.rs | 10 ++++++++ crates/ui-egui/src/menubar.rs | 2 ++ crates/ui-egui/src/menus.rs | 5 ++++ crates/ui-egui/src/panels/bottombar.rs | 5 ++++ crates/ui-egui/src/panels/detail.rs | 33 ++++++++++++++++++++++---- crates/ui-egui/src/state.rs | 3 +++ docs/parity.md | 2 +- 10 files changed, 119 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index debe6f68c..56869d1ff 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -446,6 +446,32 @@ 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})) + } + ), // ---- 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 }))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68200a356..da624241f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -125,6 +125,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. @@ -223,6 +226,7 @@ impl Session { undo: Vec::new(), redo: Vec::new(), interaction: None, + skip_auto_write: false, clipboard: None, meta_clipboard: None, browse: None, @@ -308,8 +312,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/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 5b2919d0b..2c0f8f045 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -218,6 +218,36 @@ fn regions_from_sidecar_are_read_on_import() { 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); +} + fn synthetic_dng(xmp: &str) -> Vec { synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default()) } 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/menubar.rs b/crates/ui-egui/src/menubar.rs index fce32157a..65bbce425 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -123,6 +123,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "view.leftPanel", "view.photoCounts", + "view.faceBoxes", "view.filmstrip", "view.histogram", "view.navigator", @@ -284,6 +285,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "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), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a4a8ced9d..7f38e1af8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -22,6 +22,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("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"), @@ -264,6 +265,10 @@ 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()})) diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 049a8fbd6..e36cba6a5 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -166,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 262f41bbb..0fe2e5925 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -333,7 +333,11 @@ 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); - region_overlay(&p, &map, &photo); + if app.ui.face_boxes + && let Some(index) = region_overlay(ui, &p, &map, &photo) + { + let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); + } // 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; @@ -421,16 +425,34 @@ 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))); } -/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Read-only — -/// LightCraft doesn't write regions yet. -fn region_overlay(p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) { +/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows +/// a × in its corner; clicking it returns that region's index so the caller can remove it (a +/// catalog-only edit: LightCraft doesn't write regions to XMP). +fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) -> Option { let t = Tokens::get(p.ctx()); let clip = p.clip_rect(); - for r in &photo.meta.regions { + let pointer = ui.input(|i| i.pointer.hover_pos()); + let mut remove = None; + for (index, r) in photo.meta.regions.iter().enumerate() { let rect = Rect::from_two_pos(map.screen(Point::new(r.rect.x0, r.rect.y0)), map.screen(Point::new(r.rect.x1, r.rect.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 pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { + let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.top() + 11.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() { + remove = Some(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)); @@ -451,6 +473,7 @@ fn region_overlay(p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog 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)); } + remove } /// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e14cabb5..04567c41f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -264,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 @@ -550,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/docs/parity.md b/docs/parity.md index b29c5e069..cd9aedab0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -145,7 +145,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | -| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) and removed with the × shown on hover (catalog-only and undoable; the sidecar is never rewritten for it). 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" | From e5848aa1b246db85ef7667f5be6c50d4657fa2d1 Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:14:56 -0400 Subject: [PATCH 11/30] Filters: open a library unfiltered, and show the active filters in the loupe A date, keyword or person left over from the last session silently hid photos behind a small badge, so view.json no longer stores or restores the filter (the source, sort and selection still come back). In the loupe a small pill at the canvas' top left names the active filters (the filmstrip and Next / Previous follow them) and clears them on a click; it sits in the canvas margin, so the photo does not move. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/library.rs | 16 +++++---------- crates/engine/src/tests_organize.rs | 19 ++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 31 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 11 deletions(-) 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/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/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0fe2e5925..0d1944b53 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -333,6 +333,9 @@ 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 && let Some(index) = region_overlay(ui, &p, &map, &photo) { @@ -476,6 +479,34 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig remove } +/// 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) { From 9bb06cc9dc12ee63eedfe37fa6ffd717c06a9a31 Mon Sep 17 00:00:00 2001 From: Daniel Voshart <205798369+voshart@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:33:38 -0400 Subject: [PATCH 12/30] Faces: resize a face box with eight drag handles Hovering a box shows handles at its corners and edge midpoints; dragging one previews the new box live and commits once on release, as one undo step, through the new `photo.setRegion {id?, index, rect}`: catalog-only like removal, never rewriting the sidecar, with the rectangle validated (finite numbers only, clamped to the photo, at least 0.5 % each way) and a test with a real sidecar. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/library.rs | 34 ++++++++++ crates/engine/src/tests_xmp.rs | 38 +++++++++++ crates/ui-egui/src/panels/detail.rs | 101 ++++++++++++++++++++++++---- docs/parity.md | 2 +- 4 files changed, 161 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 56869d1ff..491b25047 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -472,6 +472,40 @@ pub fn specs() -> Vec { 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 }))?; diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 2c0f8f045..16cd013b4 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -248,6 +248,44 @@ fn removing_a_region_is_undoable_and_leaves_the_sidecar_alone() { 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); +} + fn synthetic_dng(xmp: &str) -> Vec { synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default()) } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0d1944b53..6af194bfa 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -336,10 +336,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !fullscreen { filter_pill(app, ui, canvas); } - if app.ui.face_boxes - && let Some(index) = region_overlay(ui, &p, &map, &photo) - { - let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); + if app.ui.face_boxes { + match region_overlay(ui, &p, &map, &photo) { + 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.")) { @@ -428,21 +434,90 @@ 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; clicking it returns that region's index so the caller can remove it (a -/// catalog-only edit: LightCraft doesn't write regions to XMP). -fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) -> Option { +/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once, +/// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: +/// LightCraft doesn't write regions to XMP. +fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) -> Option { let t = Tokens::get(p.ctx()); let clip = p.clip_rect(); let pointer = ui.input(|i| i.pointer.hover_pos()); - let mut remove = None; + // 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 rect = Rect::from_two_pos(map.screen(Point::new(r.rect.x0, r.rect.y0)), map.screen(Point::new(r.rect.x1, r.rect.y1))); + let dragging = live.filter(|(i, _)| *i == index); + let norm = 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 pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { - let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.top() + 11.0), vec2(16.0, 16.0)); + 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 new = 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), + }; + 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 }); @@ -453,7 +528,7 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig 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() { - remove = Some(index); + edit = Some(RegionEdit::Remove(index)); } } let Some(name) = &r.name else { continue }; @@ -476,7 +551,7 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig 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)); } - remove + edit } /// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous diff --git a/docs/parity.md b/docs/parity.md index cd9aedab0..65bdfc1d6 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -145,7 +145,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | -| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) and removed with the × shown on hover (catalog-only and undoable; the sidecar is never rewritten for it). No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | | LR-LIB-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" | From 22c6aa69181861eb045cc3b45c1f16599c5ca7ce Mon Sep 17 00:00:00 2001 From: Trey Reynolds Date: Tue, 6 Oct 2026 17:21:46 -0500 Subject: [PATCH 13/30] Issue #150: Raw: NEF colour from the camera JPEG; 12-bit NEF black level LightCraft has no colour matrix for Nikon cameras. Because of this, NEF files showed colours that were too gray and too green. - Colour: LightCraft now calculates a colour matrix and a tone curve from the JPEG preview in each NEF file. Sony ARW files use the same procedure and the same quality checks. White balance is relative to the as-shot look, as for ARW files. - One function, camera_preview::file_local_look, selects the formats for the fit and for the relative white balance. Photo::relative_wb in the catalog uses the same formats. - Black level: Nikon stores the black level (maker note 0x003d) in 14-bit units, also in 12-bit files. The decoder read it as a 12-bit value. Because of this, 12-bit files from the D750, D780, D850, D7500 and Z 50 were too dark. Now the decoder divides the value by 4 for 12-bit files. - The corpus gets the D7500 12-bit and 14-bit lossless samples. A test compares their levels. The render cache version increases to 9. Results on 13 NEF files that LightCraft can decode: LightCraft accepted the fit for all 13 files. The mean colour difference from the camera JPEG (CIE76 dE) decreased from 13-46 to 2.9-7.8. One file with mixed light improved by a small quantity only (12.7 to 12.0). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 12 ++++---- crates/catalog/src/model.rs | 22 +++++++++++-- crates/engine/src/camera_preview.rs | 48 +++++++++++++++++++++++++---- crates/engine/src/files.rs | 4 +-- crates/engine/src/media.rs | 2 +- crates/raw/src/vendor/nef.rs | 39 +++++++++++++++++++++-- crates/raw/tests/corpus.rs | 29 +++++++++++++++++ docs/camera-preview-colour.md | 12 ++++++-- docs/parity.md | 6 ++-- docs/whats-new.md | 4 +++ xtask/src/main.rs | 8 +++++ 11 files changed, 159 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c1cc9d8a7..9f73c1f1d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,8 +23,8 @@ 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 ⬜ | @@ -36,7 +36,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | 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) | +| 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 | | 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,7 +82,7 @@ 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 | ⬜ | diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 8b400bb52..f66584547 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -331,10 +331,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 +422,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/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 54b308b13..41bbdca84 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1,12 +1,13 @@ -//! 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. use lightcraft_color::{Mat3, luminance_2020}; use lightcraft_pipeline::tone::{CameraTone, ToneMap}; use lightcraft_raster::{ Rgb32f, resample::{Filter, fit}, }; -use lightcraft_raw::{RawImage, color::CameraTransform}; +use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform}; #[derive(Clone, Copy, Debug)] pub(crate) struct CameraLook { @@ -14,8 +15,16 @@ pub(crate) struct CameraLook { pub tone: CameraTone, } +/// 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 jpeg = lightcraft_raw::embedded_preview(bytes)?; @@ -38,7 +47,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain))); let look = fit_pairs(&sensor, &reference)?; if lightcraft_pipeline::profiling() { - eprintln!("[profile] ARW camera look: {:?}, {:?}", look.matrix.0, look.tone); + eprintln!("[profile] {:?} camera look: {:?}, {:?}", raw.format, look.matrix.0, look.tone); } Some(look) } @@ -137,7 +146,11 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option { } } 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 !after.is_finite() || samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) { return None; @@ -248,6 +261,29 @@ mod tests { assert!(fit_pairs(&sensor, &reference).is_some()); } + #[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/files.rs b/crates/engine/src/files.rs index f4a57a6f3..b268333cf 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -132,7 +132,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 { @@ -285,7 +285,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.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/media.rs b/crates/engine/src/media.rs index c61470d0f..bab0c5c7d 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 = 8; +pub const RENDER_CACHE_VERSION: u64 = 9; /// 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]; 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/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 93aada18e..6c847c890 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 @@ -28,6 +28,12 @@ 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). +### 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. diff --git a/docs/parity.md b/docs/parity.md index fc94d08a7..f1d6486c8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 + tone curve, 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 + tone curve, relative WB; 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). Missing: CR3, 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 | @@ -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 + tone curve, relative WB, 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 + tone curve, relative WB, 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 3045deadf..c25638f9f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -124,6 +124,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/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", From 6bda431c4b9d58dab370d07960eec2f72720b454 Mon Sep 17 00:00:00 2001 From: Maurice Rogers Date: Tue, 6 Oct 2026 19:30:23 -0600 Subject: [PATCH 14/30] LR-LIB-DELETE: restore from Recently Deleted; re-adding a deleted photo shows where it is Photos in Recently Deleted could not be restored from the app: photo.restore and photo.deletePermanently existed only as engine commands, and adding the same file again just said "1 duplicate skipped" (the duplicate check counts deleted photos), with the side panel that lists Recently Deleted collapsed by default. - Restore and Delete Permanently in the Photo menu and the photo context menu, in place of Delete, when the selection is in Recently Deleted. - The filmstrip gets the photo context menu (it had none). - An import that only finds files the library already has selects those photos: in Recently Deleted (side panel opened, how to restore or delete permanently and import afresh) or in All Photos. Mixed imports note how many are in Recently Deleted. The message stays 6 s. - Import review badges say "In Recently Deleted"; badges are translated. - Japanese strings, parity rows, what's new; headless regression test. --- crates/engine/src/cmd/library.rs | 4 +- crates/ui-egui/locales/ja-formats.json | 5 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/src/headless.rs | 81 ++++++++++++++++++++++++++ crates/ui-egui/src/import.rs | 53 ++++++++++++++++- crates/ui-egui/src/lib.rs | 7 ++- crates/ui-egui/src/menubar.rs | 18 +++++- crates/ui-egui/src/panels/detail.rs | 2 + crates/ui-egui/src/panels/grid.rs | 9 ++- docs/parity.md | 12 ++-- docs/whats-new.md | 5 ++ 11 files changed, 183 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 74e5d5b0f..cba1c3382 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -453,11 +453,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/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 9da942f87..354205c8d 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -100,5 +100,8 @@ "… 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}・右クリック ▸ 復元、または完全に削除してから読み込み直し" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4e5c71a5f..2a6f18a03 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1017,5 +1017,6 @@ "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": "最近削除した写真内" } 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/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..20e54730f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -344,8 +344,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) { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e0..30d8f6f62 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -192,6 +192,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.tagFromTracklog", "---", "photo.delete", + "photo.restore", + "photo.deletePermanently", ], ), ( @@ -252,11 +254,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, } } @@ -828,8 +839,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/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0697b75cc..3448fbf5a 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1300,6 +1300,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/grid.rs b/crates/ui-egui/src/panels/grid.rs index 760abbfe0..edc4662f3 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -905,7 +905,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/docs/parity.md b/docs/parity.md index fc94d08a7..04861b1b7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 15 | 3 | 2 | 1 | 8/8 (100%) | 6/8 (75%) | +| X. Cross-cutting behaviours (BEHAV) | 15 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 31 | 9 | 40 | 9 | — | 21/22 (95%) | -| **Total** | 387 | 29 | 91 | 37 | 194/200 (97%) | 139/149 (93%) | +| **Total** | 387 | 30 | 90 | 37 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.2% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.3%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.6% of 158. ## Top gaps @@ -148,7 +148,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | | | 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 | @@ -600,7 +600,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) | diff --git a/docs/whats-new.md b/docs/whats-new.md index 3045deadf..92bad949b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -83,6 +83,11 @@ 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.) - 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. From 13a3eff2e813f88b3df9daf198cd88d0fce93f83 Mon Sep 17 00:00:00 2001 From: Maurice Rogers Date: Tue, 6 Oct 2026 20:00:48 -0600 Subject: [PATCH 15/30] LR-IMP-FORMATS: CR3 full-size preview and metadata from the ISO-BMFF container CR3 files imported as preview-only with the 1620x1080 PRVW JPEG and no metadata (no camera, capture time, exposure, lens or GPS): nothing read the container. - crates/meta/src/cr3.rs: bounded ISO base media box parser for CR3 (Canon uuid box with CMT1-4 and THMB, tracks with their sample entries, CMP1 coding header and sample location, XMP uuid box). Every size/offset checked; depth and box count bounded. - Metadata: CMT1 (IFD0), CMT2 (Exif) and CMT4 (GPS) merged into one TIFF Exif block, so CR3 reads like any TIFF-based raw; XMP from its uuid box. - Preview: the full-size JPEG track (6960x4640 on an EOS R6 Mark III) before PRVW/THMB; imports report the full image size. - Tests: synthetic CR3 (boxes, tracks, merged Exif), truncation/corruption sweep, JPEG track preferred over PRVW with fallback. Raw (CRX) data is still not decoded; CR3 stays preview-only. --- ROADMAP.md | 2 +- crates/meta/src/container.rs | 5 + crates/meta/src/cr3.rs | 344 +++++++++++++++++++++++++++++++++++ crates/meta/src/lib.rs | 4 +- crates/raw/src/preview.rs | 40 +++- docs/parity.md | 2 +- docs/whats-new.md | 4 + 7 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 crates/meta/src/cr3.rs diff --git a/ROADMAP.md b/ROADMAP.md index c1cc9d8a7..6593c9b98 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). |---|---:|---| | 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 | +| 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 | 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..594129d03 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; 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/docs/parity.md b/docs/parity.md index fc94d08a7..6a5a007e7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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. 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, 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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 3045deadf..49f8634f4 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -83,6 +83,10 @@ changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel. ### Library +- 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 picks + up the full-size preview; their camera metadata comes with a fresh import. - 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. From c9fe78e1784fb6971ae4b432fa4150b58896982c Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:27:15 -0700 Subject: [PATCH 16/30] docs: add star history chart to README Show repository star growth at the bottom of the README. Link the date-based chart with a top-left legend to the corresponding Star History page. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index e7454f249..5e741b7cb 100644 --- a/README.md +++ b/README.md @@ -446,3 +446,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) From 9adb40908a5ed598e0605b577ee598b38daa4c1c Mon Sep 17 00:00:00 2001 From: Maurice Rogers Date: Tue, 6 Oct 2026 21:53:09 -0600 Subject: [PATCH 17/30] LR-IMP-FORMATS: Reload from Disk in the Photo and context menus; fills missing camera metadata photo.reload had no menu path, so the documented way to refresh CR3s imported before their metadata was read (Photo > Reload from Disk) did not exist in the app. It is now in the Photo menu and the photo context menu, and besides size/preview-only it fills camera fields the catalog lacks (camera, lens, exposure, GPS, capture time) from a fresh probe, never overwriting a set value. --- crates/engine/src/cmd/convert.rs | 72 +++++++++++++++++++++++++++++-- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/src/menubar.rs | 1 + crates/ui-egui/src/panels/grid.rs | 3 ++ docs/whats-new.md | 5 ++- 5 files changed, 78 insertions(+), 6 deletions(-) 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/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4e5c71a5f..288e02626 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1017,5 +1017,6 @@ "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…": "バックアップからライブラリを復元…", + "Reload from Disk": "ディスクから再読み込み" } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e0..d876bffbb 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -186,6 +186,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "photo.saveMetadataToFile", "photo.readMetadataFromFile", + "photo.reload", "app.showInFinder", "dialog.rename", "dialog.captureTime", diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 760abbfe0..405cbf752 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -821,6 +821,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!({})); } diff --git a/docs/whats-new.md b/docs/whats-new.md index 49f8634f4..d2698a966 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -85,8 +85,9 @@ ### Library - 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 picks - up the full-size preview; their camera metadata comes with a fresh import. + 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. From 22eb4d3515c37077414f3a5fda14ff37ceda8f9a Mon Sep 17 00:00:00 2001 From: goujicheng Date: Wed, 7 Oct 2026 11:34:33 +0800 Subject: [PATCH 18/30] LR-BEHAV-LOCALIZE: a language table, Simplified Chinese, and per-script font fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The localization layer was hardcoded to English and Japanese: an enum with two variants, an `is_japanese()` gate compiled into the format macro, and one catalog pair. Adding a language meant editing the menus, the settings row, the checkmark logic, the format macro and the font stack — each in a different place. A language is now one entry in `i18n::language_table!`: BCP-47 code, endonym, ISO 15924 script and catalog. The Language menu, the settings row, the shortcut sheet and the control channel are all built from that table, and `build.rs` picks up `locales/-formats.json` by name, so a further language is a table entry plus two catalogs (docs/localization.md). Simplified Chinese (`zh-hans`) ships as the second translation: `zh-hans.json` (1019 entries, key-set and placeholder-identical to `ja.json`) and `zh-hans-formats.json` (102 entries). Its glyphs come from craft-fonts' Noto Sans CJK SC, whose `Hans` faces the UI now installs *before* the Japanese ones for a Chinese reader, so shared Han characters keep the language's forms (`fonts::cjk_fallback`); the export watermark accepts every CJK face. `tr_format!` keeps its compile-time check. It formats the English source at the call site — a runtime template cannot be handed to `format!` — and names those values so a translation can place them by name (`{n}`) or position (`{_1}`). Every translation is still checked by `format!`, so a translation with an unknown placeholder fails the build. The Japanese catalog was rewritten from the old `{:.0}` plural-discard trick to that by-name form; behaviour is unchanged. Also: settings persist the BCP-47 code (never the Rust variant name), `LIGHTCRAFT_LANGUAGE` accepts system-style tags (`zh_CN`, `ja_JP.UTF-8`), `xtask parity` reads the second command table, and the glyph tests cover every catalog character plus the interface symbols (`▸`/`▾` turned out to have no glyph in any installed font and are gone from the Chinese text). Chinese and Japanese both need `CRAFT_FONTS_DIR` as before; the web build still embeds only the Japanese face (Noto Sans CJK SC alone would push the wasm past Cloudflare's 25 MiB per-file cap), documented in docs/localization-zh-hans.md. Co-Authored-By: DeepSeek V3.2 --- README.md | 6 +- ROADMAP.md | 16 +- apps/lightcraft/src/native_menu.rs | 2 +- crates/engine/src/export.rs | 13 +- crates/engine/src/fonts.rs | 60 +- crates/ui-egui/build.rs | 97 +- crates/ui-egui/locales/ja-formats.json | 122 +-- crates/ui-egui/locales/zh-hans-formats.json | 104 ++ crates/ui-egui/locales/zh-hans.json | 1021 +++++++++++++++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/i18n.rs | 546 ++++++---- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/menubar.rs | 6 +- crates/ui-egui/src/menus.rs | 32 +- crates/ui-egui/src/panels/dialogs.rs | 5 +- crates/ui-egui/src/panels/settings.rs | 3 +- crates/ui-egui/src/shortcuts.rs | 8 +- crates/ui-egui/src/state.rs | 2 +- crates/ui-egui/src/theme.rs | 28 +- docs/localization-ja.md | 13 +- docs/localization-zh-hans.md | 36 + docs/localization.md | 79 ++ docs/parity.md | 9 +- xtask/src/parity.rs | 15 +- 24 files changed, 1914 insertions(+), 313 deletions(-) create mode 100644 crates/ui-egui/locales/zh-hans-formats.json create mode 100644 crates/ui-egui/locales/zh-hans.json create mode 100644 docs/localization-zh-hans.md create mode 100644 docs/localization.md diff --git a/README.md b/README.md index e7454f249..403d3d3d0 100644 --- a/README.md +++ b/README.md @@ -334,14 +334,14 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light cargo xtask ci # fmt, clippy, tests, layering, wasm checks ``` -**Japanese text** needs the shared font repo, an optional build input (official releases always include it): +**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it): ```sh git clone https://github.com/storytold/craft-fonts ../craft-fonts CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft ``` -Without it LightCraft builds and runs the same, but Japanese text has no glyphs. Fonts are never committed to this +Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI @@ -432,7 +432,7 @@ Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel (1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA, *Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font: Inter (SIL OFL 1.1). Builds made with [craft-fonts](https://github.com/storytold/craft-fonts) (all official releases) -also embed its Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its +also embed its Chinese and Japanese fonts (Noto Sans CJK SC, BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its [ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). All icons are original. The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the diff --git a/ROADMAP.md b/ROADMAP.md index c1cc9d8a7..c6e0a42e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | | **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | | **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export | -| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load | +| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Chinese/Japanese/English UI (see docs/localization.md); remaining technical errors and date formats; accessibility partial; headless UI tests time out under machine load | ### By kind of user @@ -87,7 +87,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | | M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) | -| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Japanese/English localisation 🟡; accessibility and other locales ⬜) | +| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Chinese/Japanese/English localisation 🟡; accessibility and further locales ⬜) | ## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02) @@ -192,12 +192,14 @@ own chart-based calibration (M11.4). Adobe matrices are never used. export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added tracker rows for camera colour, camera coverage and render fidelity. -## Japanese interface and text watermarks +## Chinese and Japanese interfaces, and text watermarks -English/Japanese interface language is persisted in UI state. Core menus have Japanese -translations; untranslated panels and dialogs retain English. Japanese glyphs (UI: BIZ UDPGothic; -watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made with the -optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept +English, Simplified Chinese and Japanese interface languages are persisted in UI state, and the +language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another +language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are +translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI: +BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made +with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept `vertical: true` in export JSON/presets and expose an orientation selector. Japanese characters stay upright in top-to-bottom columns, with newlines starting columns to the left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 8af250a5b..33b5c8676 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec)]) -> String { out.push(';'); } } - let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese()); + let mut s = format!("{};", lightcraft_ui_egui::i18n::uses_cjk_fonts()); for (t, items) in bar { s.push_str(t); walk(items, &mut s); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index ab0d8edf0..6affb8b4d 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -245,14 +245,15 @@ impl Default for Watermark { /// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); -/// The watermark faces: Inter first, then the craft-fonts Japanese faces (Mincho first, the -/// watermark's serif look; then any other Japanese face). Without craft-fonts that is Inter alone, -/// and Japanese characters draw as Inter's missing-glyph box. +/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's +/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is +/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's +/// missing-glyph box. fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec> { - let mut japanese: Vec<_> = crate::fonts::japanese(craft).collect(); - japanese.sort_by_key(|f| !f.is_mincho()); + let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); + cjk.sort_by_key(|f| !f.is_mincho()); std::iter::once(WATERMARK_FONT) - .chain(japanese.into_iter().map(|f| f.bytes)) + .chain(cjk.into_iter().map(|f| f.bytes)) .filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok()) .collect() } diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 954d92631..c028ba53e 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,9 +1,9 @@ //! Fonts from the optional craft-fonts build input (storytold/craft-fonts). //! //! Built with `CRAFT_FONTS_DIR=`, [`CRAFT_FONTS`] holds every font in its -//! manifest (today: the Japanese UI and document fonts); otherwise it is empty and LightCraft uses -//! only its own bundled fonts (Inter). Both the egui UI and the export watermark renderer read it -//! from here. See craftrules `standards/fonts.md`. +//! manifest (today: the Japanese and Simplified-Chinese UI and document fonts); otherwise it is +//! empty and LightCraft uses only its own bundled fonts (Inter). Both the egui UI and the export +//! watermark renderer read it from here. See craftrules `standards/fonts.md`. /// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). pub struct CraftFont { @@ -33,6 +33,43 @@ pub fn japanese(fonts: &'static [CraftFont]) -> impl Iterator impl Iterator { + fonts.iter().filter(|f| f.scripts.iter().any(|script| is_cjk_script(script))) +} + +/// Whether an ISO 15924 script is written with Han, kana or hangul characters (Unicode's CJK +/// scripts), which Inter has no glyphs for. +pub fn is_cjk_script(script: &str) -> bool { + matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang") +} + +/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script +/// first (for `style` when asked), then every other CJK face. One CJK face list serves every +/// language, but Han characters are shared between them: a Chinese reader must get the Chinese +/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first. +/// Empty without craft-fonts. +pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> { + let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect(); + faces.sort_by_key(|font| { + ( + // The language's own script first. A face dedicated to the script is preferred over a + // multi-script face (Noto Sans CJK SC claims `Hans,Latn`; a Japanese face claims `Jpan`). + !font.covers(script), + font.scripts.len() != 1, + font.family != "BIZ UDPGothic", + font.style != style, + font.is_mincho(), + ) + }); + faces +} + +/// Whether the font is for any CJK script. +fn covers_cjk(font: &CraftFont) -> bool { + font.scripts.iter().any(|script| is_cjk_script(script)) +} + #[cfg(test)] mod tests { use super::*; @@ -49,4 +86,21 @@ mod tests { } assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces"); } + + /// Each language's own faces come first, so shared Han keeps the language's forms, and Latin + /// text still reaches Inter before any CJK fallback. + #[test] + fn a_language_gets_its_own_script_first() { + static FACES: &[CraftFont] = &[ + CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] }, + CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] }, + CraftFont { family: "BIZ UDMincho", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] }, + ]; + let first = |script: &str| cjk_fallback(FACES, script, "Regular").first().map(|f| f.family); + assert_eq!(first("Hans"), Some("Noto Sans CJK SC")); + assert_eq!(first("Jpan"), Some("BIZ UDPGothic")); + // Every CJK face is still offered to every language. + assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3); + assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0); + } } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 75c3e03c9..600997fdd 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,18 +1,89 @@ -//! Compile translated format strings so Rust checks both languages' placeholders. +//! Compile the translated format strings into the `tr_format!` macro. +//! +//! `locales/-formats.json` maps an English format string to its translation for one language. +//! Each catalog becomes a `translated_format` match arm, so every language's placeholders are +//! checked by Rust's `format!` — a wrong placeholder is a compile error, not a runtime `{}`. +//! Adding a language means adding its file; this script picks it up by name. use std::{collections::BTreeMap, env, fs, path::PathBuf}; fn main() -> Result<(), Box> { - println!("cargo:rerun-if-changed=locales/ja-formats.json"); - let messages: BTreeMap = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?; - let mut source = String::from("macro_rules! tr_format {\n"); - for (english, japanese) in messages { - let en = serde_json::to_string(&english)?; - let ja = serde_json::to_string(&japanese)?; - source.push_str(&format!( - "({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n" - )); - } - source.push_str("}\npub(crate) use tr_format;\n"); - fs::write(PathBuf::from(env::var("OUT_DIR")?).join("ja-formats.rs"), source)?; + println!("cargo:rerun-if-changed=build.rs"); + let out = PathBuf::from(env::var("OUT_DIR")?); + fs::write(out.join("tr-formats.rs"), generate()?)?; Ok(()) } + +fn generate() -> Result> { + let mut catalogs = Vec::new(); + for entry in fs::read_dir("locales")? { + let path = entry?.path(); + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_string(); + // `en` is the source language: it has no catalog, its formats are the source themselves. + let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue }; + println!("cargo:rerun-if-changed=locales/{name}"); + let messages: BTreeMap = serde_json::from_str(&fs::read_to_string(&path)?)?; + let variant = code.split(['-', '_']).filter(|part| !part.is_empty()).map(capitalize).collect::(); + catalogs.push((variant, messages)); + } + catalogs.sort_by(|a, b| a.0.cmp(&b.0)); + + // `tr_format!` formats the English source at the call site (where `format!` can check it) and + // also names every value, which is what lets a translation place them. Positional values are + // addressed as `_1`, `_2`, … in call-site order; `macro_rules!` cannot count, so the slot + // numbering is spelled out here and grows with `POSITIONAL_SLOTS` if a call site ever needs more. + const POSITIONAL_SLOTS: usize = 12; + let mut macro_source = String::from(concat!( + "/// Translate a format string with runtime values, keeping the English source's placeholders\n", + "/// checked by `format!` at the call site. `build.rs` writes this macro from `locales/`.\n", + "macro_rules! tr_format {\n", + " ($format:literal $(, $($args:tt)*)?) => {\n", + " $crate::i18n::tr_format!(@scan () () (_positional) ; $format; $($($args)*)?)\n", + " };\n", + " (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal; $name:ident = $value:expr $(, $($rest:tt)*)?) => {\n", + " $crate::i18n::tr_format!(@scan ($($raw)* $name = $value,) ($($values)* (stringify!($name), format!(\"{}\", $value)),)\n", + " $position ; $format; $($($rest)*)?)\n", + " };\n", + )); + for slot in 1..=POSITIONAL_SLOTS { + macro_source.push_str(&format!( + " (@scan ($($raw:tt)*) ($($values:tt)*) (_positional) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{slot}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n (@scan ($($raw:tt)*) ($($values:tt)*) (_{slot}) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{next}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n", + next = slot + 1 + )); + } + macro_source.push_str(concat!( + " (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal;) => {\n", + " $crate::i18n::format_message($format, format!($format, $($raw)*), &[$($values)*])\n", + " };\n", + "}\n", + "\n", + "// `macro_rules!` is textual: the module re-export is what lets call sites keep saying\n", + "// `crate::i18n::tr_format!`.\n", + "pub(crate) use tr_format;\n", + "\n", + "impl Locale {\n", + " /// The translated format string for `source`, or `None` when this language has none.\n", + " ///\n", + " /// Generated by `build.rs` from `locales/-formats.json`.\n", + " pub fn translated_format(self, source: &str) -> Option<&'static str> {\n", + " match self {\n", + )); + let mut source = macro_source; + for (variant, messages) in &catalogs { + source.push_str(&format!(" Locale::{variant} => match source {{\n")); + for (english, translated) in messages { + source.push_str(&format!(" {} => Some({}),\n", serde_json::to_string(english)?, serde_json::to_string(translated)?)); + } + source.push_str(" _ => None,\n },\n"); + } + source.push_str(" _ => None,\n }\n }\n}\n"); + Ok(source) +} + +/// `zh-hans` → `ZhHans`, the enum variant the `language_table!` entry declares. +fn capitalize(part: &str) -> String { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 9da942f87..467ebba80 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -1,19 +1,19 @@ { " · {skipped} skipped (file exists)": "・{skipped}件は同名ファイルのためスキップ", - " · {} duplicate{} skipped": "・重複{}枚をスキップ{:.0}", - " · {} failed: {}": "・{}件失敗:{}", - " · {} moved": "・{}件移動", - " · {} not readable": "・{}件は読み込み不可", - ", {failed} file{} not readable": "、{failed}件を読み込めません{:.0}", + " · {} duplicate{} skipped": "・重複{_1}枚をスキップ", + " · {} failed: {}": "・{_1}件失敗:{_2}", + " · {} moved": "・{_1}件移動", + " · {} not readable": "・{_1}件は読み込み不可", + ", {failed} file{} not readable": "、{failed}件を読み込めません", "Add {local_n} to My Photos": "{local_n}枚をマイフォトに追加", - "Add {} Photo{}": "{}枚の写真を追加{:.0}", - "Add “{}” to Favorites": "「{}」をお気に入りに追加", - "Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}", - "Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}", + "Add {} Photo{}": "{_1}枚の写真を追加", + "Add “{}” to Favorites": "「{_1}」をお気に入りに追加", + "Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました", + "Added {n} photo{} to “{}”": "「{_2}」に{n}枚を追加しました", "All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします", "Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用", - "Automatic ({} MB)": "自動({}MB)", - "Click {}:1": "クリック時 {}:1", + "Automatic ({} MB)": "自動({_1}MB)", + "Click {}:1": "クリック時 {_1}:1", "Create {n} Virtual Copies": "{n}枚の仮想コピーを作成", "Decoder: {reason}": "デコーダー:{reason}", "Delete \"{label}\"": "「{label}」を削除", @@ -22,83 +22,83 @@ "Delete “{current}”": "「{current}」を削除", "Duplicate \"{label}\"": "「{label}」を複製", "Example: {example}": "例:{example}", - "Exported {n} photo{}": "{n}枚を書き出しました{:.0}", - "Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました{:.0}", - "Exported {} point curve preset{}": "{}件のポイントカーブを書き出しました{:.0}", - "Exported {} preset{}": "{}件のプリセットを書き出しました{:.0}", - "Exporting {} of {total}": "書き出し中:{total}枚中{}枚", - "Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中{:.0}", - "Healed {n} dust spot{}": "{n}か所のほこりを修復しました{:.0}", - "Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました{:.0}", - "Imported {n} preset{}": "{n}件のプリセットを読み込みました{:.0}", - "Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました{:.0}。{f}件は読み込めません{:.0}", - "Imported {} photo{}": "{}枚を読み込みました{:.0}", - "Matches: {}": "一致条件:{}", - "Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%", + "Exported {n} photo{}": "{n}枚を書き出しました", + "Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました", + "Exported {} point curve preset{}": "{_1}件のポイントカーブを書き出しました", + "Exported {} preset{}": "{_1}件のプリセットを書き出しました", + "Exporting {} of {total}": "書き出し中:{total}枚中{_1}枚", + "Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中", + "Healed {n} dust spot{}": "{n}か所のほこりを修復しました", + "Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました", + "Imported {n} preset{}": "{n}件のプリセットを読み込みました", + "Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました。{f}件は読み込めません", + "Imported {} photo{}": "{_1}枚を読み込みました", + "Matches: {}": "一致条件:{_1}", + "Merging… {stage} {:.0}%": "合成中… {stage} %", "Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?", "Named ({named_n})": "名前付き({named_n})", - "Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)", + "Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け(コピーされた設定だけを適用)", "Preset: {name}": "プリセット:{name}", - "Preview only — editing the camera's embedded JPEG ({})": "プレビューのみ:カメラ内蔵のJPEGを編集中({})", - "Preview only — {}": "プレビューのみ:{}", - "Profile: {}": "プロファイル:{}", - "Proof Copy ({})": "校正用コピー({})", - "Proof Preview · {}": "校正プレビュー・{}", + "Preview only — editing the camera's embedded JPEG ({})": "プレビューのみ:カメラ内蔵のJPEGを編集中({_1})", + "Preview only — {}": "プレビューのみ:{_1}", + "Profile: {}": "プロファイル:{_1}", + "Proof Copy ({})": "校正用コピー({_1})", + "Proof Preview · {}": "校正プレビュー・{_1}", "Quick Develop · {n} photos": "クイック現像・{n}枚", - "Rated {}": "評価:{}", + "Rated {}": "評価:{_1}", "Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚", - "Redo {}": "やり直す:{}", - "Remove “{}” from Favorites": "「{}」をお気に入りから削除", + "Redo {}": "やり直す:{_1}", + "Remove “{}” from Favorites": "「{_1}」をお気に入りから削除", "Rename {n} Photos…": "{n}枚の写真の名前を変更…", "Rename “{name}”": "「{name}」の名前を変更", "Rendering on the CPU: {why}": "CPUで描画:{why}", - "Reset {} Channel": "{}チャンネルをリセット", - "Saved export preset “{}”": "書き出しプリセット「{}」を保存しました", - "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}", - "Showing {n} of {}": "{}枚中{n}枚を表示", - "Smart album: {}": "スマートアルバム:{}", - "Undo {}": "取り消す:{}", - "Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{} — Rust製の写真管理・RAW現像アプリ", - "Version: {}": "バージョン:{}", + "Reset {} Channel": "{_1}チャンネルをリセット", + "Saved export preset “{}”": "書き出しプリセット「{_1}」を保存しました", + "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示", + "Showing {n} of {}": "{_1}枚中{n}枚を表示", + "Smart album: {}": "スマートアルバム:{_1}", + "Undo {}": "取り消す:{_1}", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{_1} — Rust製の写真管理・RAW現像アプリ", + "Version: {}": "バージョン:{_1}", "none (CPU rendering): {why}": "なし(CPU描画):{why}", "the {n} selected photos": "選択中の{n}枚", - "the {} photos in view": "表示中の{}枚", + "the {} photos in view": "表示中の{_1}枚", "these {count} photos": "この{count}枚の写真", - "{count} smart preview{} · {:.1} MB": "スマートプレビュー{count}枚{:.0}・{:.1}MB", + "{count} smart preview{} · {:.1} MB": "スマートプレビュー{count}枚・{_1}MB", "{f} folders (and their subfolders)": "{f}個のフォルダとそのサブフォルダ", "{label} Grading": "{label}のカラーグレーディング", "{label} {d:+} on every selected photo": "選択したすべての写真の{label}を{d:+}調整", "{label}, {n} photos": "{label}、{n}枚", "{matching} of {t} photos": "{t}枚中{matching}枚", "{matching} photos": "{matching}枚", - "{mode} spot {} of {}": "{mode}:{1}か所中{0}か所", + "{mode} spot {} of {}": "{mode}:{_2}か所中{_1}か所", "{msg} ({failed} failed)": "{msg}({failed}件失敗)", "{name} Label": "{name}のラベル", "{name} Off": "{name}を無効にする", "{name} On": "{name}を有効にする", "{name} — double-click to reset it": "{name}:ダブルクリックでリセット", - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n}件の変更{:.0}をディスクに保存できません:{e}\n自動で再試行します。終了すると変更が失われます{:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n}件の変更をディスクに保存できません:{e}\n自動で再試行します。終了すると変更が失われます", "{n} found · {sel} selected": "{n}枚検出・{sel}枚選択", "{n} photos": "{n}枚", - "{n} photos in {} are hidden by the filters above": "{}の{n}枚は上のフィルターで非表示になっています", - "{n} photo{}": "{n}枚{:.0}", - "{n} photo{} match · updates automatically as photos change": "{n}枚が一致{:.0}・写真の変更に応じて自動更新", - "{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新", + "{n} photos in {} are hidden by the filters above": "{_1}の{n}枚は上のフィルターで非表示になっています", + "{n} photo{}": "{n}枚", + "{n} photo{} match · updates automatically as photos change": "{n}枚が一致・写真の変更に応じて自動更新", + "{n} photo{} now · updates automatically as photos change": "現在{n}枚・写真の変更に応じて自動更新", "{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}", - "{stage}… {:.0}%": "{stage}… {:.0}%", + "{stage}… {:.0}%": "{stage}… %", "{what} merge added": "{what}合成を追加しました", "{what} merge failed: {e}": "{what}合成に失敗:{e}", - "{} and higher": "{}以上", - "{} files": "{}個のファイル", - "{} files and {f} folder{}": "{}個のファイルと{f}個のフォルダ{:.0}", - "{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{}本:垂直または水平にする線に沿ってドラッグ", - "{} photos": "{}枚", - "{} photo{} not readable": "{}枚を読み込めません{:.0}", - "{} spot(s) on this photo": "この写真に{}か所のスポット", + "{} and higher": "{_1}以上", + "{} files": "{_1}個のファイル", + "{} files and {f} folder{}": "{_1}個のファイルと{f}個のフォルダ", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{_1}本:垂直または水平にする線に沿ってドラッグ", + "{} photos": "{_1}枚", + "{} photo{} not readable": "{_1}枚を読み込めません", + "{} spot(s) on this photo": "この写真に{_1}か所のスポット", "· {dups} already in the library": "・{dups}枚は読み込み済み", - "· {n} photo{}": "・{n}枚{:.0}", + "· {n} photo{}": "・{n}枚", "… and {more} more": "…ほか{more}件", - "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}", - "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚", - "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚" + "Import cancelled · {} photo{} added": "読み込みを中止しました・{_1}枚を追加", + "Reading photos… {} of {}": "写真を確認中:{_2}枚中{_1}枚", + "Adding photos… {} of {}": "写真を追加中:{_2}枚中{_1}枚" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json new file mode 100644 index 000000000..a1e9f86c2 --- /dev/null +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -0,0 +1,104 @@ +{ + " · {skipped} skipped (file exists)": " · 已跳过 {skipped} 个(文件已存在)", + " · {} duplicate{} skipped": " · 已跳过 {_1} 个重复项", + " · {} failed: {}": " · {_1} 个失败:{_2}", + " · {} moved": " · 已移动 {_1} 个", + " · {} not readable": " · {_1} 个无法读取", + ", {failed} file{} not readable": ",{failed} 个文件无法读取", + "Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片", + "Add {} Photo{}": "添加 {_1} 张照片", + "Add “{}” to Favorites": "将“{_1}”添加到收藏夹", + "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片", + "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{_2}”", + "All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。", + "Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片", + "Automatic ({} MB)": "自动({_1} MB)", + "Click {}:1": "单击 {_1}:1", + "Create {n} Virtual Copies": "创建 {n} 个虚拟副本", + "Decoder: {reason}": "解码器:{reason}", + "Delete \"{label}\"": "删除 \"{label}\"", + "Delete \"{label}\" (and the mask)": "删除 \"{label}\"(及蒙版)", + "Delete {n} Photos": "删除 {n} 张照片", + "Delete “{current}”": "删除“{current}”", + "Duplicate \"{label}\"": "复制 \"{label}\"", + "Example: {example}": "示例:{example}", + "Exported {n} photo{}": "已导出 {n} 张照片", + "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片", + "Exported {} point curve preset{}": "已导出 {_1} 个点曲线预设", + "Exported {} preset{}": "已导出 {_1} 个预设", + "Exporting {} of {total}": "正在导出 {total} 张中的第 {_1} 张", + "Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选", + "Healed {n} dust spot{}": "已修复 {n} 处尘点", + "Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设", + "Imported {n} preset{}": "已导入 {n} 个预设", + "Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取", + "Imported {} photo{}": "已导入 {_1} 张照片", + "Matches: {}": "匹配项:{_1}", + "Merging… {stage} {:.0}%": "合成中… {stage} %", + "Move {what} to Recently Deleted?": "将 {what} 移到最近删除?", + "Named ({named_n})": "已命名({named_n})", + "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置", + "Preset: {name}": "预设:{name}", + "Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({_1})", + "Preview only — {}": "仅预览 — {_1}", + "Profile: {}": "配置文件:{_1}", + "Proof Copy ({})": "校样副本({_1})", + "Proof Preview · {}": "校样预览 · {_1}", + "Quick Develop · {n} photos": "快速编辑 · {n} 张照片", + "Rated {}": "已评级 {_1}", + "Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张", + "Redo {}": "重做:{_1}", + "Remove “{}” from Favorites": "从收藏夹中移除“{_1}”", + "Rename {n} Photos…": "重命名 {n} 张照片…", + "Rename “{name}”": "重命名“{name}”", + "Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}", + "Reset {} Channel": "重置 {_1} 通道", + "Saved export preset “{}”": "已保存导出预设“{_1}”", + "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置", + "Showing {n} of {}": "显示 {_1} 张中的 {n} 张", + "Smart album: {}": "智能相册:{_1}", + "Undo {}": "撤销 {_1}", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {_1} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。", + "Version: {}": "版本:{_1}", + "none (CPU rendering): {why}": "无(CPU 渲染):{why}", + "the {n} selected photos": "选中的 {n} 张照片", + "the {} photos in view": "视图中的 {_1} 张照片", + "these {count} photos": "这 {count} 张照片", + "{count} smart preview{} · {:.1} MB": "{count} 个智能预览 · {_1} MB", + "{f} folders (and their subfolders)": "{f} 个文件夹(及其子文件夹)", + "{label} Grading": "{label} 颜色分级", + "{label} {d:+} on every selected photo": "每张选中照片的 {label} {d:+}", + "{label}, {n} photos": "{label},{n} 张照片", + "{matching} of {t} photos": "{t} 张照片中的 {matching} 张", + "{matching} photos": "{matching} 张照片", + "{mode} spot {} of {}": "{mode}:第 {_1} 个污点,共 {_2} 个", + "{msg} ({failed} failed)": "{msg}({failed} 项失败)", + "{name} Label": "{name} 标签", + "{name} Off": "已关闭 {name}", + "{name} On": "已开启 {name}", + "{name} — double-click to reset it": "{name} — 双击可重置", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 项更改已保存在内存中,但尚未写入磁盘:{e}\nLightCraft 会自动重试;现在退出将丢失这些更改。", + "{n} found · {sel} selected": "找到 {n} 张 · 已选 {sel} 张", + "{n} photos": "{n} 张照片", + "{n} photos in {} are hidden by the filters above": "{_1} 中的 {n} 张照片被上方的过滤器隐藏", + "{n} photo{}": "{n} 张照片", + "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新", + "{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新", + "{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… %", + "{what} merge added": "已添加 {what} 合并", + "{what} merge failed: {e}": "{what} 合并失败:{e}", + "{} and higher": "{_1} 及以上", + "{} files": "{_1} 个文件", + "{} files and {f} folder{}": "{_1} 个文件和 {f} 个文件夹", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {_1} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。", + "{} photos": "{_1} 张照片", + "{} photo{} not readable": "{_1} 张照片无法读取", + "{} spot(s) on this photo": "此照片上有 {_1} 处污点", + "· {dups} already in the library": "· {dups} 张已在图库中", + "· {n} photo{}": "· {n} 张照片", + "… and {more} more": "…以及另外 {more} 项", + "Import cancelled · {} photo{} added": "导入已取消 · 已添加 {_1} 张照片", + "Reading photos… {} of {}": "正在读取照片… 第 {_1} 张,共 {_2} 张", + "Adding photos… {} of {}": "正在添加照片… 第 {_1} 张,共 {_2} 张" +} diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json new file mode 100644 index 000000000..3ebd08d20 --- /dev/null +++ b/crates/ui-egui/locales/zh-hans.json @@ -0,0 +1,1021 @@ +{ + "+ Group": "+ 组", + "+ Rule": "+ 条件", + "0: nothing is rejected.": "0:不排除任何照片。", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "筛选器正在隐藏此视图中的照片:请修改筛选条件,或清除筛选(视图 → 清除筛选)", + "About LightCraft": "关于 LightCraft", + "Activity": "活动", + "Add": "添加", + "Add Catchlight": "添加眼神光", + "Add Folder to Local": "将文件夹添加到本地", + "Add Selected Photos": "添加所选照片", + "Add a rule (⌥-click: a group)": "添加条件(按 ⌥ 单击:添加组)", + "Add in place": "在原位置添加", + "Add in place: the library references the files where they are; nothing is copied or moved.": "在原位置添加:图库直接引用文件所在位置;不会复制或移动任何内容。", + "Add keyword": "添加关键字", + "Add number": "添加编号", + "Add to": "添加到", + "Add to Album": "添加到相册", + "Add to Favorites": "添加到收藏夹", + "Add to My Photos": "添加到我的照片", + "Add to Selected Photos": "添加到所选照片", + "Add/Subtract/Intersect Mask": "添加/减去/相交蒙版", + "Added to photos you import that don't already have it.": "导入照片时,仅为尚未包含该信息的照片添加。", + "Adding": "正在添加", + "After": "修改后", + "After 30 days": "30 天后", + "After 7 days": "7 天后", + "After 90 days": "90 天后", + "After a year": "一年后", + "Album": "相册", + "Albums": "相册", + "All": "全部", + "All Metadata": "所有元数据", + "All Metadata…": "所有元数据…", + "All Photos": "所有照片", + "All rights reserved": "保留所有权利", + "All types": "所有类型", + "Alt Text": "替代文本", + "Always": "始终", + "Amount": "数量", + "Aperture": "光圈", + "Application": "应用程序", + "Apply Preset": "应用预设", + "Approved": "已批准", + "Aqua": "青色", + "Aqua Hue": "青色色相", + "Aqua Luminance": "青色明度", + "Aqua Saturation": "青色饱和度", + "As Shot": "拍摄时设置", + "As shot": "拍摄时设置", + "Ascending": "升序", + "Aspect": "长宽比", + "Aspect Ratio": "长宽比", + "Assisted Culling": "辅助筛选", + "Assisted Culling…": "辅助筛选…", + "Auto": "自动", + "Auto Advance": "自动前进", + "Auto Advance Off": "关闭自动前进", + "Auto Advance On": "开启自动前进", + "Auto Advance after rating or flagging": "评分或加旗标后自动前进", + "Auto Advance: move to the next photo after rating or flagging": "自动前进:评分或加旗标后移到下一张照片", + "Auto Align": "自动对齐", + "Auto Crop": "自动裁剪", + "Auto Import": "自动导入", + "Auto Mask": "自动蒙版", + "Auto Settings": "自动设置", + "Auto Sync": "自动同步", + "Auto-Stack by Capture Time": "按拍摄时间自动堆叠", + "Auto-Stack by Capture Time…": "按拍摄时间自动堆叠…", + "Auto-Tag from Tracklog": "从轨迹日志自动标记", + "Auto-Tag from Tracklog…": "从轨迹日志自动标记…", + "Automatic": "自动", + "Automatically Write Changes into XMP": "自动将更改写入 XMP", + "Automatically write changes into XMP sidecars": "自动将更改写入 XMP 附属文件", + "B&W": "黑白", + "B&W Mix": "黑白混合", + "B&W Mixer": "黑白混色器", + "Back": "返回", + "Back to Edit": "返回编辑", + "Back to Edit (E)": "返回编辑 (E)", + "Back to Grid": "返回网格视图", + "Background": "背景", + "Balance": "平衡", + "Before": "修改前", + "Before/After (Y)": "修改前/修改后 (Y)", + "Before/After Settings": "修改前/修改后设置", + "Before/After Split": "修改前/修改后分屏", + "Before/After Split Top/Bottom": "修改前/修改后上下分屏", + "Before/After Top/Bottom": "修改前/修改后上下排列", + "Bit depth": "位深度", + "Black & White": "黑白", + "Blacks": "黑色色阶", + "Blending": "混合", + "Blue": "蓝色", + "Blue Hue": "蓝色色相", + "Blue Luminance": "蓝色明亮度", + "Blue Primary": "蓝色原色", + "Blue Saturation": "蓝色饱和度", + "Blue channel": "蓝色通道", + "Blue/Yellow Fringe": "蓝/黄边缘", + "Blurry photos below the score are flagged as rejects.": "低于该分值的模糊照片将被标记为排除。", + "Boundary Warp": "边界变形", + "Bring All to Front": "全部置于顶层", + "Browse Folder…": "浏览文件夹…", + "Browse Profiles": "浏览配置文件", + "Browse…": "浏览…", + "Brush": "画笔", + "Brush Stroke": "画笔笔触", + "Build Previews": "构建预览", + "Build Smart Previews": "构建智能预览", + "By Date": "按日期", + "By day (YYYY/YYYY-MM-DD)": "按日 (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "按月 (YYYY/YYYY-MM)", + "Calibration": "校准", + "Camera": "相机", + "Cameras and Cards": "相机和存储卡", + "Cancel": "取消", + "Candidate": "候选", + "Caption": "题注", + "Capture Date": "拍摄日期", + "Captured": "拍摄时间", + "Check All": "全部选中", + "Choose Edit Settings to Copy": "选择要复制的编辑设置", + "Choose Edit Settings to Copy…": "选择要复制的编辑设置…", + "Choose Folder…": "选择文件夹…", + "Choose a photo in the grid or filmstrip": "请在网格视图或胶片窗格中选择一张照片", + "Choose…": "选择…", + "City": "城市", + "Clarity": "清晰度", + "Clear": "清除", + "Clear Cache": "清除缓存", + "Clear Filters": "清除过滤器", + "Clear Guides": "清除参考线", + "Clear History": "清除历史记录", + "Clear Quick Collection": "清空快速集合", + "Clear Reference": "清除参考照片", + "Clear all": "全部清除", + "Click Zoom Ratio": "单击缩放比例", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "单击标签即可插入。示例基于 2026 年 1 月 14 日 05:58:48 拍摄的 IMG_0042.CR3。", + "Click photos in the grid to toggle the keyword; Esc stops": "在网格中单击照片可切换关键字;按 Esc 结束", + "Click the photo to pick a colour · ⇧-click adds more": "单击照片选择颜色 · 按 ⇧ 单击可添加更多", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "单击照片选择颜色;按 ⇧ 单击可添加更多(最多 5 个)", + "Clone": "仿制", + "Close": "关闭", + "Close Panel": "关闭面板", + "Close Window": "关闭窗口", + "Cloudy": "阴天", + "Collapse All Stacks": "折叠所有堆叠", + "Color": "颜色", + "Color Grading": "颜色分级", + "Color Mixer": "颜色混合器", + "Color Noise Reduction": "颜色降噪", + "Color Priority": "颜色优先", + "Color Range": "颜色范围", + "Color space": "色彩空间", + "Color space: sRGB (AVIF)": "色彩空间:sRGB(AVIF)", + "Compare": "比较", + "Compare (Shift+C)": "比较(Shift+C)", + "Compare Before and After": "比较修改前和修改后", + "Compare needs at least two photos": "比较至少需要两张照片", + "Component options": "组件选项", + "Compression": "压缩", + "Confirm before deleting": "删除前确认", + "Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认", + "Constrain Crop": "限制裁剪", + "Contrast": "对比度", + "Copy": "复制", + "Copy After's Settings to Before": "将修改后的设置复制到修改前", + "Copy Before's Settings to After": "将修改前的设置复制到修改后", + "Copy Edit Settings": "复制编辑设置", + "Copy History Step Settings to Before": "将历史记录步骤的设置复制到修改前", + "Copy Metadata": "复制元数据", + "Copy as DNG": "拷贝为 DNG", + "Copy into the library (else use the files where they are)": "拷贝到图库中(否则使用文件当前位置)", + "Copy the files (into the library's Originals/, or a folder you choose)": "拷贝文件(到图库的 Originals/ 或您选择的文件夹)", + "Copy to": "拷贝到", + "Copy to Clipboard": "拷贝到剪贴板", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "拷贝:文件将拷贝到下方文件夹,图库使用这些副本;原始文件保持原样。", + "Copyright": "版权", + "Copyright Info URL": "版权信息 URL", + "Copyright Status": "版权状态", + "Country": "国家/地区", + "Create Album": "创建相册", + "Create Album…": "创建相册…", + "Create Folder": "创建文件夹", + "Create Folder…": "创建文件夹…", + "Create New Mask": "创建新蒙版", + "Create Preset": "创建预设", + "Create Preset…": "创建预设…", + "Create Proof Copy": "创建校样副本", + "Create Smart Album": "创建智能相册", + "Create Smart Album from Filter…": "从筛选器创建智能相册…", + "Create Smart Album…": "创建智能相册…", + "Create Stack": "创建堆叠", + "Create Version": "创建版本", + "Create Version from Step": "从该步骤创建版本", + "Create Virtual Copy": "创建虚拟副本", + "Creator": "创建者", + "Crop": "裁剪", + "Crop & Rotate": "裁剪和旋转", + "Crop & Rotate (C)": "裁剪和旋转(C)", + "Culling": "挑片", + "Curve": "曲线", + "Custom": "自定义", + "Cylindrical": "圆柱形", + "Darken": "压暗", + "Darks": "暗部", + "Date Groups": "日期分组", + "Day": "日", + "Daylight": "日光", + "Defringe": "去边", + "Deghost Amount": "去伪影量", + "Dehaze": "去朦胧", + "Delete": "删除", + "Delete (⌫)": "删除 (⌫)", + "Delete Album": "删除相册", + "Delete All Masks": "删除所有蒙版", + "Delete Keyword": "删除关键字", + "Delete Mask": "删除蒙版", + "Delete Permanently": "永久删除", + "Delete Photo": "删除照片", + "Delete Photos": "删除照片", + "Delete Preset": "删除预设", + "Delete Version": "删除版本", + "Delete all": "删除全部", + "Delete all spots": "删除所有污点", + "Delete mask": "删除蒙版", + "Delete them": "删除它们", + "Density": "密度", + "Depth Range": "深度范围", + "Descending": "降序", + "Deselect All": "取消全选", + "Detail": "细节", + "Detail (D)": "细节 (D)", + "Diagonal": "对角线", + "Discard Preview Cache": "丢弃预览缓存", + "Display P3": "Display P3", + "Distortion": "扭曲", + "Don't enlarge": "不放大", + "Done": "完成", + "Drag along the horizon; double-click for Auto": "沿地平线拖动;双击以自动校正", + "Drag over an eye on the photo; the pupil inside is found automatically.": "在照片中的眼睛上拖动;会自动找到其中的瞳孔。", + "Draw Guides": "绘制参考线", + "Duplicate": "创建副本", + "Duplicate Mask": "复制蒙版", + "Duplicate and Invert Mask": "复制并反转蒙版", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每个 / 开始一层文件夹;标签按照片填充(缺少元数据的层级为“unknown”)。", + "Edit": "编辑", + "Edit (E)": "编辑 (E)", + "Edit Capture Time": "编辑拍摄时间", + "Edit Capture Time…": "编辑拍摄时间…", + "Edit Color Label Names": "编辑颜色标签名称", + "Edit Label Names…": "编辑标签名称…", + "Edit Sections": "编辑板块", + "Edit Smart Album": "编辑智能相册", + "Edit Smart Album…": "编辑智能相册…", + "Edit in External Editor": "在外部编辑器中编辑", + "Edited": "已编辑", + "Effects": "效果", + "Enable Lens Corrections": "启用镜头校正", + "Enter Full Screen": "进入全屏", + "Erase": "擦除", + "Every EXIF, GPS and XMP field in the file": "文件中的每个 EXIF、GPS 和 XMP 字段", + "Existing previews": "现有预览", + "Expand All Stacks": "展开所有堆叠", + "Export": "导出", + "Export Album with Preset": "使用预设导出相册", + "Export Album…": "导出相册…", + "Export Group…": "导出分组…", + "Export Now": "立即导出", + "Export Point Curve Presets": "导出点曲线预设", + "Export Presets": "导出预设", + "Export Presets…": "导出预设…", + "Export User Presets…": "导出用户预设…", + "Export with Preset": "使用预设导出", + "Export with Previous": "使用上次设置导出", + "Exporting": "正在导出", + "Export…": "导出…", + "Exposure": "曝光", + "Exposure (shutter · aperture · ISO)": "曝光 (快门 · 光圈 · ISO)", + "Extended Description": "扩展描述", + "External Editor": "外部编辑器", + "Favorites": "收藏夹", + "Feather": "羽化", + "File": "文件", + "File & date": "文件 & 日期", + "File Name": "文件名", + "File Path": "文件路径", + "File Size": "文件大小", + "File name": "文件名", + "Fill Edges": "填充边缘", + "Filmstrip": "胶片窗格", + "Filter": "过滤器", + "Filter Bar": "过滤器栏", + "Filter Presets": "过滤器预设", + "Filter bar": "过滤器栏", + "Filter fields": "过滤字段", + "Find Dust Spots": "查找灰尘污点", + "Find Missing Photos…": "查找丢失的照片…", + "Find Similar Photos": "查找相似照片", + "Fit": "适合", + "Fit (F)": "适合 (F)", + "Flag": "旗标", + "Flag as Pick": "标记为留用", + "Flag as Reject": "标记为排除", + "Flash": "闪光灯", + "Flip H": "水平翻转", + "Flip Horizontal": "水平翻转", + "Flip Overlay (⇧O)": "翻转叠加 (⇧O)", + "Flip V": "垂直翻转", + "Flip Vertical": "垂直翻转", + "Flow": "流量", + "Fluorescent": "荧光灯", + "Focal length": "焦距", + "Folder": "文件夹", + "Folder name (renamed on disk; its photos follow)": "文件夹名称 (在磁盘上重命名;照片随之更新)", + "Folders": "文件夹", + "Folders and {date} use the capture time; a photo without one uses today's date.": "文件夹和{date}使用拍摄时间;没有拍摄时间的照片使用今天的日期。", + "Forget unchanged photos": "忘记未更改的照片", + "Format": "格式", + "Forward": "前进", + "Free": "自由", + "Full": "完整", + "Full Screen Preview": "全屏预览", + "Full size": "完整大小", + "GPS Track Log": "GPS 轨迹日志", + "General": "常规", + "Geometry": "几何", + "Global Hue": "全局色相", + "Global Luminance": "全局明亮度", + "Global Saturation": "全局饱和度", + "Glossy": "光面纸", + "Golden": "黄金比例", + "Grain": "颗粒", + "Graphic": "图形", + "Green": "绿色", + "Green Amount": "绿色量", + "Green Hue": "绿色色相", + "Green Hue High": "绿色色相上限", + "Green Hue Low": "绿色色相下限", + "Green Luminance": "绿色明亮度", + "Green Primary": "绿色原色", + "Green Saturation": "绿色饱和度", + "Green channel": "绿色通道", + "Grid": "网格视图", + "Grid Info": "网格信息", + "Group": "分组", + "Group by Day": "按日分组", + "Group by Month": "按月分组", + "Group by Year": "按年分组", + "Group by date": "按日期分组", + "Group into Stack": "合并为堆叠", + "Guided": "引导式", + "Guided Upright": "引导式透视校正", + "HDR Merge": "HDR 合并", + "HDR Panorama Merge": "HDR 全景合并", + "HDR Panorama…": "HDR 全景…", + "HDR…": "HDR…", + "Heal": "修复", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑", + "Height": "高度", + "Height (px)": "高度(px)", + "Help": "帮助", + "Hide LightCraft": "隐藏 LightCraft", + "Hide Mask": "隐藏蒙版", + "Hide Others": "隐藏其他", + "Hides this shortcut only; the folder and its photos stay as they are": "仅隐藏此快捷方式;文件夹及其中的照片保持不变", + "High": "高", + "Highlight": "高光", + "Highlights": "高光", + "Highlights Hue": "高光色相", + "Highlights Luminance": "高光明度", + "Highlights Saturation": "高光饱和度", + "Highlights split": "高光分界", + "Histogram": "直方图", + "History": "历史记录", + "History & Activity (Y)": "历史记录与活动(Y)", + "Horizontal": "水平", + "Hue": "色相", + "Hue Range": "色相范围", + "If file exists": "如果文件已存在", + "Import": "导入", + "Import Date": "导入日期", + "Import Photos": "导入照片", + "Import Photos…": "导入照片…", + "Import Point Curve Presets": "导入点曲线预设", + "Import Presets": "导入预设", + "Import Presets…": "导入预设…", + "Import Profiles & Presets…": "导入配置文件与预设…", + "Import from Device": "从设备导入", + "Import from Folder…": "从文件夹导入…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "使用“文件 → 导入照片…”(Cmd+Shift+I)导入照片,或将照片拖放到此处", + "Imported Photos": "已导入的照片", + "Importing": "正在导入", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "导入会扫描源中的照片,但不会将该文件夹添加到“本地”(若想在不导入的情况下处理某个文件夹,请使用“本地 → 浏览文件夹…”)。", + "In library": "在图库中", + "In memory — nothing is saved": "仅存于内存 — 不会保存任何内容", + "In use": "使用中", + "In-memory session: these settings last until LightCraft quits.": "内存会话:这些设置仅在 LightCraft 退出前有效。", + "Include subfolders": "包含子文件夹", + "Info": "信息", + "Info (I)": "信息(I)", + "Info overlay": "信息叠加显示", + "Interface": "界面", + "Intersect": "相交", + "Intersect with": "相交于", + "Into one folder": "合并到一个文件夹", + "Invert": "反相", + "Invert Mask": "反相蒙版", + "Keep in Local": "保留在本地", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "请选择空间充足的驱动器:智能预览每张照片约 1 MB。缩略图缓存仍保留在图库中。", + "Keyboard Shortcuts": "键盘快捷键", + "Keyboard shortcuts": "键盘快捷键", + "Keyword": "关键字", + "Keyword Painter": "关键字画笔", + "Keyword Set": "关键字集", + "Keyword Sets": "关键字集", + "Keyword Suggestions": "关键字建议", + "Keyword to paint": "要涂抹的关键字", + "Keywords": "关键字", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "您添加的关键字会显示在此处;按 ⌥1–⌥9 即可应用。", + "Landscape": "横向", + "Language": "语言", + "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "详细视图渲染的最大长边;在大显示器上越大越清晰,但速度更慢。", + "Last": "上次", + "Last view": "上次视图", + "Lateral Chromatic Aberration": "横向色差", + "Leave them": "保留", + "Lens": "镜头", + "Lens Corrections": "镜头校正", + "Level": "水平", + "Library": "图库", + "Library Originals": "图库原始文件", + "Light": "亮度", + "LightCraft Default": "LightCraft 默认", + "LightCraft Help": "LightCraft 帮助", + "LightCraft Preset": "LightCraft 预设", + "LightCraft Presets": "LightCraft 预设", + "LightCraft Website": "LightCraft 网站", + "LightCraft on GitHub": "GitHub 上的 LightCraft", + "LightCraft website": "LightCraft 网站", + "LightCraft — Second Window": "LightCraft — 第二窗口", + "Lights": "高光", + "Limit file size (KB, 0 = off)": "限制文件大小(KB,0 = 关闭)", + "Linear Gradient": "线性渐变", + "List this folder in Local from now on": "以后在本地中列出此文件夹", + "Local": "本地", + "Local folders": "本地文件夹", + "Local library — no cloud account needed": "本地图库 — 无需云账户", + "Locate Missing File…": "定位缺失的文件…", + "Location": "位置", + "Long Edge": "长边", + "Looking for photos…": "正在查找照片…", + "Lossless": "无损", + "Low": "低", + "Luminance": "明度", + "Luminance Range": "明度范围", + "Magenta": "品红色", + "Magenta Hue": "品红色色相", + "Magenta Luminance": "品红色明度", + "Magenta Saturation": "品红色饱和度", + "Make Candidate the Select": "将候选设为选择", + "Manual": "手动", + "Masking": "蒙版", + "Masking (M)": "蒙版 (M)", + "Masks": "蒙版", + "Match": "匹配", + "Matte": "哑光", + "Med": "中", + "Megapixels": "百万像素", + "Memory for caches": "缓存内存", + "Menu": "菜单", + "Merge": "合并", + "Merge Keywords": "合并关键字", + "Merge into…": "合并到…", + "Merging…": "正在合并…", + "Metadata": "元数据", + "Metadata Preset": "元数据预设", + "Metadata Presets": "元数据预设", + "Metadata preset": "元数据预设", + "Midpoint": "中点", + "Midtones": "中间调", + "Midtones Hue": "中间调色相", + "Midtones Luminance": "中间调明亮度", + "Midtones Saturation": "中间调饱和度", + "Midtones split": "中间调分割点", + "Minimize": "最小化", + "Missing Photos": "缺失的照片", + "Mode": "模式", + "Modified Date": "修改日期", + "Monochrome": "黑白", + "Month": "月", + "More": "更多", + "More preset options": "更多预设选项", + "Move": "移动", + "Move Down in Stack": "在堆叠中下移", + "Move Folder To…": "将文件夹移动到…", + "Move Up in Stack": "在堆叠中上移", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "移动文件 (移到图库的 Originals/,或你选择的文件夹),并从源位置移除", + "Move them": "移动它们", + "Move to": "移动到", + "Move to Group": "移动到组", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "移动:文件将移动到下面的文件夹;每个原始文件 (及其 XMP 附属文件) 仅在副本校验通过后才会从源位置移除。重复的文件和失败的文件保留在原处。", + "My Photos": "我的照片", + "Name": "名称", + "Named": "已命名", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "名称会显示在标签菜单、筛选栏和信息面板中,并写入 XMP。留空 = 颜色的名称。", + "Navigator": "导航器", + "Needs Work": "待处理", + "Neutral": "中性", + "Never": "从不", + "New Album…": "新建相册…", + "New Folder…": "新建文件夹…", + "New Group…": "新建组…", + "New Smart Album": "新建智能相册", + "New Smart Album from Filter…": "从筛选器新建智能相册…", + "New Smart Album…": "新建智能相册…", + "New album": "新建相册", + "New album…": "新建相册…", + "New name": "新名称", + "No": "否", + "No Camera or Card Found": "未找到相机或存储卡", + "No Date Groups": "不按日期分组", + "No Stars": "无星级", + "No Tool": "无工具", + "No automatic versions.": "没有自动版本。", + "No camera": "无相机信息", + "No date": "无日期", + "No dust spots found": "未找到污点", + "No edits yet.": "尚未编辑。", + "No exposure information": "无曝光信息", + "No filter presets yet": "暂无筛选器预设", + "No lens data embedded in this file (DNG lens opcodes only).": "此文件中未嵌入镜头数据 (仅支持 DNG 镜头操作码)。", + "No masks yet. Choose a mask type above.": "尚无蒙版。请在上方选择蒙版类型。", + "No matching photos": "没有匹配的照片", + "No metadata found": "未找到元数据", + "No new presets": "没有新预设", + "No photo selected": "未选择照片", + "No photos": "没有照片", + "No photos found": "未找到照片", + "No photos in this folder": "此文件夹中没有照片", + "No photos match the active filters": "没有照片符合当前筛选条件", + "No photos match the filters (View → Clear Filters)": "没有照片符合筛选条件 (视图 → 清除筛选)", + "No raw photos yet: cameras appear here once their photos are in the library.": "尚无 RAW 照片:照片进入图库后,相机会显示在此处。", + "No reference photo": "无参考照片", + "No usable GPU found: rendering on the CPU": "未找到可用的 GPU:改用 CPU 渲染", + "No versions yet. Create one to keep this look.": "尚无版本。创建一个以保留当前效果。", + "Noise": "噪点", + "Noise Reduction": "降噪", + "Non-raw photos": "非 RAW 照片", + "None": "无", + "Nothing to compare": "没有可比较的照片", + "Nothing to survey": "没有可筛选的照片", + "OK": "确定", + "Object": "对象", + "Off": "关闭", + "Offset X": "水平偏移", + "Offset Y": "垂直偏移", + "Opacity": "不透明度", + "Opacity (%)": "不透明度(%)", + "Open Library": "打开图库", + "Open Library…": "打开图库…", + "Open in": "打开方式", + "Open in Detail": "在放大视图中打开", + "Open the place in OpenStreetMap": "在 OpenStreetMap 中打开该地点", + "Optics": "镜头校正", + "Optional name": "名称(可选)", + "Orange": "橙色", + "Orange Hue": "橙色色相", + "Orange Luminance": "橙色明亮度", + "Orange Saturation": "橙色饱和度", + "Original": "原始比例", + "Original files": "原始文件", + "Original offline · editing the smart preview": "原图离线 · 正在编辑智能预览", + "Original offline · no smart preview": "原图离线 · 无智能预览", + "Originals": "原图", + "Other images (JPEG, PNG, TIFF, HEIC…)": "其他图像(JPEG、PNG、TIFF、HEIC…)", + "Overlay": "叠加显示", + "Overwrite": "覆盖", + "Paint": "绘制", + "Paint only areas like the one under the brush": "仅绘制与画笔下相似的区域", + "Paint over a distraction on the photo to remove it.": "在照片上要移除的干扰物上涂抹。", + "Paint overlay": "蒙版叠加显示", + "Panorama Merge": "全景合并", + "Panorama…": "全景…", + "Parametric curve": "参数曲线", + "Paste Edit Settings": "粘贴编辑设置", + "Paste Metadata": "粘贴元数据", + "Paste Selected Settings": "粘贴所选设置", + "Paste Selected Settings…": "粘贴所选设置…", + "Percent": "百分比", + "Percentage": "百分比", + "Performance": "性能", + "Perspective": "透视", + "Pet Eye": "宠物眼", + "Photo": "照片", + "Photo Grid": "照片网格", + "Photo Grid (G)": "照片网格(G)", + "Photo Merge": "照片合并", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "“照片 » 在外部编辑器中编辑”(⇧⌘E)会渲染一份 16 位 TIFF 副本,与原图堆叠并在此打开(macOS 上填应用名称,其他平台填程序路径)。", + "Photos": "照片", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "仅在“本地”中查看过、从未添加或更改的照片,若其文件夹在此时长内未被浏览,将移出目录(打开图库时检查)。文件保留在磁盘上;浏览该文件夹即可再次显示。", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "进入此文件夹的照片(联机拍摄、扫描仪、同步应用)在传输完成后即被添加。", + "Pick": "留用", + "Pick a colour on the photo with the eyedropper.": "用吸管在照片上取色。", + "Pick the sharpest photo of each burst": "为每组连拍选取最清晰的照片", + "Picked": "已留用", + "Picks": "留用", + "Pixels": "像素", + "Point Color": "点颜色", + "Point Curve": "点曲线", + "Point Curve Presets": "点曲线预设", + "Point curve": "点曲线", + "Portrait": "人像", + "Position": "位置", + "Preset": "预设", + "Preset name": "预设名称", + "Presets": "预设", + "Presets & Profiles": "预设和配置文件", + "Presets (Shift+P)": "预设 (Shift+P)", + "Presets ▾": "预设 ⌄", + "Preview": "预览", + "Preview only": "仅预览", + "Preview size": "预览大小", + "Previews": "预览", + "Print": "打印", + "Profile": "配置文件", + "Profile Distortion": "配置文件扭曲", + "Profile Vignetting": "配置文件暗角", + "Profile:": "配置文件:", + "Profiles": "配置文件", + "Projection": "投影", + "Public domain": "公有领域", + "Pupil Size": "瞳孔大小", + "Purple": "紫色", + "Purple Amount": "紫色量", + "Purple Hue": "紫色色相", + "Purple Hue High": "紫色色相上限", + "Purple Hue Low": "紫色色相下限", + "Purple Luminance": "紫色亮度", + "Purple Saturation": "紫色饱和度", + "Put the locations you removed from Local back (no files change)": "恢复你从本地移除的位置 (不更改文件)", + "Quality": "品质", + "Quick Collection": "快速集合", + "Quick Develop": "快速编辑", + "Quit LightCraft": "退出 LightCraft", + "Radial Gradient": "径向渐变", + "Radius": "半径", + "Range": "范围", + "Rating": "星级", + "Ratings & flags": "星级和旗标", + "Raw default": "RAW 默认值", + "Raw defaults": "RAW 默认值", + "Raw files": "RAW 文件", + "Raw photos": "RAW 照片", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "将 RAW 照片导出为内嵌编辑设置的 DNG。大小、颜色和输出选项不适用。", + "Read Metadata from File": "从文件读取元数据", + "Reading": "正在读取", + "Reading folder…": "正在读取文件夹…", + "Recently Added": "最近添加", + "Recently Deleted": "最近删除", + "Red": "红", + "Red Eye": "红眼", + "Red Hue": "红色色相", + "Red Luminance": "红色亮度", + "Red Primary": "红色原色", + "Red Saturation": "红色饱和度", + "Red channel": "红色通道", + "Red/Cyan Fringe": "红/青边缘", + "Redo": "重做", + "Reference": "参考", + "Reference View": "参考视图", + "Reference the files where they are": "在原地引用文件", + "Refresh Source (/)": "刷新源 (/)", + "Reject below focus": "排除对焦低于此值的照片", + "Rejected": "已排除", + "Rejects": "排除的照片", + "Remove": "移除", + "Remove (H)": "移除 (H)", + "Remove CA": "移除色差", + "Remove Chromatic Aberration": "移除色差", + "Remove from Album": "从相册中移除", + "Remove from Favorites": "从收藏夹中移除", + "Remove from Local": "从本地移除", + "Remove from Photo": "从照片中移除", + "Remove from Selected Photos": "从所选照片中移除", + "Remove from Stack": "从堆叠中移出", + "Remove from survey": "从筛选视图中移除", + "Remove location info": "移除位置信息", + "Remove this filter": "移除此筛选器", + "Remove this group": "删除此组", + "Remove this rule": "删除此规则", + "Rename": "重命名", + "Rename Album": "重命名相册", + "Rename Folder…": "重命名文件夹…", + "Rename Keyword": "重命名关键字", + "Rename Keyword…": "重命名关键字…", + "Rename Photos": "重命名照片", + "Rename Photos…": "重命名照片…", + "Rename Version": "重命名版本", + "Rename…": "重命名…", + "Rendering": "正在渲染", + "Rendering…": "正在渲染…", + "Reset": "重置", + "Reset All Curves": "重置所有曲线", + "Reset Before to Import State": "将修改前重置为导入时的状态", + "Reset Control": "重置控件", + "Reset Crop": "重置裁剪", + "Reset Edits": "重置编辑", + "Reset Parametric Curve": "重置参数曲线", + "Reset Section": "重置此区段", + "Reset Tone Curve": "重置色调曲线", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重置所有曲线:点曲线(所有通道)和参数曲线(双击某个通道可单独重置该通道)", + "Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)", + "Resize to": "调整大小为", + "Resolution (ppi)": "分辨率 (ppi)", + "Restore": "恢复", + "Retouch": "润饰", + "Review Import": "查看导入内容", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "右键单击照片 » 设为参考照片,然后选择“视图 → 参考视图”(Shift+R)", + "Right-click a preset to delete it": "右键单击预设即可将其删除", + "Rights Usage Terms": "使用条款", + "Rotate": "旋转", + "Rotate Left": "向左旋转", + "Rotate Right": "向右旋转", + "Roughness": "粗糙度", + "Roundness": "圆度", + "Same as raw default": "与 RAW 默认设置相同", + "Sample a colour on the photo": "在照片上取样颜色", + "Saturation": "饱和度", + "Saturation Range": "饱和度范围", + "Save": "保存", + "Save Current Filter as Preset…": "将当前筛选器保存为预设…", + "Save Current Keywords as Set…": "将当前关键字保存为集…", + "Save Metadata to File": "将元数据保存到文件", + "Save Point Curve…": "保存点曲线…", + "Save as Preset": "保存为预设", + "Save as Smart Album…": "保存为智能相册…", + "Save as set": "保存为集", + "Save preset": "保存预设", + "Scale": "缩放", + "Scanning": "正在扫描", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "评分会保留在照片上:可使用“对焦”和“相似照片中的最佳”进行筛选或创建智能相册。", + "Screen": "屏幕", + "Search Photos": "搜索照片", + "Second Window": "第二个窗口", + "Select": "选择", + "Select All": "全选", + "Select Luminance Range": "选择亮度范围", + "Select a photo to edit": "选择要编辑的照片", + "Select by": "选择方式", + "Select by Flag, Rating or Label": "按旗标、星级或颜色标签选择", + "Select photos, then choose View → Survey (N)": "选择照片,然后选择 视图 → 筛选 (N)", + "Select two photos, then choose View → Compare (Shift+C)": "选择两张照片,然后选择 视图 → 比较 (Shift+C)", + "Selected": "已选择", + "Send Feedback…": "发送反馈…", + "Services": "服务", + "Set Color Label": "设置颜色标签", + "Set Develop Values": "设置编辑参数", + "Set Flag": "设置旗标", + "Set Point Curve": "设置点曲线", + "Set Profile": "设置配置文件", + "Set Rating": "设置星级", + "Set as Album Cover": "设为相册封面", + "Set as Before": "设为修改前", + "Set as Reference Photo": "设为参考照片", + "Set as Target Album": "设为目标相册", + "Set as Target Album (B adds to it)": "设为目标相册 (按 B 添加)", + "Set as Top of Stack": "设为堆叠顶部", + "Set the mix from the photo's colours": "根据照片颜色设置混合", + "Settings": "设置", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片的初始设置。更改这些设置不会影响图库中已有的照片。", + "Settings to include": "要包含的设置", + "Settings…": "设置…", + "Shade": "阴影", + "Shadow": "阴影", + "Shadows": "阴影", + "Shadows Hue": "阴影色相", + "Shadows Luminance": "阴影明亮度", + "Shadows Saturation": "阴影饱和度", + "Shadows Tint": "阴影色调", + "Shadows split": "阴影分界", + "Sharpen": "锐化", + "Sharpening": "锐化", + "Sharpness": "锐度", + "Short Edge": "短边", + "Show All": "显示全部", + "Show Clipping": "显示溢出警告", + "Show Deghost Overlay": "显示去重影叠加", + "Show Hidden Local Locations": "显示隐藏的本地位置", + "Show Luminance Map": "显示明亮度图", + "Show Mask": "显示蒙版", + "Show Mask Overlay": "显示蒙版叠加", + "Show Mask Pins": "显示蒙版图钉", + "Show Original": "显示原图", + "Show Photo Counts": "显示照片数量", + "Show Photos with Keyword": "显示包含该关键字的照片", + "Show Pins": "显示图钉", + "Show Source": "显示来源", + "Show Thumbnails": "显示缩略图", + "Show destination gamut warning": "显示目标色域警告", + "Show display gamut warning": "显示显示器色域警告", + "Show file names": "显示文件名", + "Show in Finder": "在 Finder 中显示", + "Show on Map": "在地图上显示", + "Show overlay (O)": "显示叠加 (O)", + "Show ratings, flags and edit badges": "显示星级、旗标和编辑标记", + "Show the Navigator while zoomed in": "放大时显示导航器", + "Show the template tags; click one to insert it": "显示模板标签;单击即可插入", + "Show/hide My Photos panel": "显示/隐藏我的照片面板", + "Shutter speed": "快门速度", + "Sidecar names": "附属文件名", + "Size": "大小", + "Size (% of short edge)": "大小 (占短边 %)", + "Size limit": "大小限制", + "Skip": "跳过", + "Sky": "天空", + "Smart Album": "智能相册", + "Smart previews": "智能预览", + "Smoothness": "平滑度", + "Soft Proofing": "软打样", + "Sort": "排序", + "Sort by": "排序方式", + "Source": "来源", + "Source code on GitHub": "GitHub 上的源代码", + "Spherical": "球面", + "Spiral": "螺旋", + "Square Grid": "正方形网格", + "Square Grid (G toggles)": "正方形网格(按 G 切换)", + "Square Grid: show file names and formats": "正方形网格:显示文件名和格式", + "Stack": "堆叠", + "Stack photos taken within this time of each other:": "将拍摄时间在此间隔内的照片堆叠在一起:", + "Stacks": "堆叠", + "Standard": "标准", + "Start at": "起始编号", + "Start number": "起始编号", + "Startup": "启动", + "State / Province": "省 / 州", + "Stop": "停止", + "Stop Building Previews": "停止构建预览", + "Stop Using as Target Album": "不再用作目标相册", + "Stopping…": "正在停止…", + "Straighten": "拉直", + "Straighten Tool": "拉直工具", + "Style": "样式", + "Subfolder": "子文件夹", + "Subfolder name": "子文件夹名称", + "Subject": "主题", + "Subtract": "减去", + "Suggestions": "建议", + "Survey": "筛选视图", + "Survey (N)": "筛选视图(N)", + "Swap": "交换", + "Swap Aspect (X)": "交换长宽比(X)", + "Swap Before and After Settings": "交换修改前和修改后的设置", + "Sync Settings": "同步设置", + "System Info": "系统信息", + "System Info…": "系统信息…", + "System default": "系统默认", + "Tags": "标签", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "标签:请参阅模板旁的“标签”。文件将在磁盘上重命名(连同其 XMP 附属文件);已有同名文件会得到 -1、-2…", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "局部调整:在照片上上下拖动以调整该处的颜色", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "局部调整:在照片上上下拖动以调整该处的曲线", + "Temp": "色温", + "Temperature": "色温", + "Template": "模板", + "Text": "文字", + "Texture": "纹理", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "保留原始文件不变,每个文件附带一个保存其编辑内容的 XMP 附属文件。", + "The photo has these settings": "此照片具有这些设置", + "They can be restored from Recently Deleted until it is emptied.": "在“最近删除”被清空之前,都可以从那里恢复。", + "Thirds": "三分法", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此文件夹不可用(驱动器已断开连接?)。在它恢复或您选择其他文件夹之前,不会构建或使用智能预览。", + "This photo can't be opened": "无法打开此照片", + "Threshold": "阈值", + "Thumbnail cache": "缩略图缓存", + "Tint": "色调", + "Title": "标题", + "Tone Curve": "色调曲线", + "Tools": "工具", + "Top Level": "顶层", + "Transfer": "传输", + "Treatment": "处理方式", + "Triangle": "三角形", + "Tungsten": "钨丝灯", + "Turn Off": "关闭", + "Turn on Include subfolders, or pick another folder under Local": "开启“包括子文件夹”,或在“本地”下选择其他文件夹", + "Uncheck All": "全部取消选中", + "Undo": "撤销", + "Unedited": "未编辑", + "Unflag": "取消旗标", + "Unflagged": "无旗标", + "Ungroup Stack": "取消堆叠", + "Unrated": "未评级", + "Unreadable": "无法读取", + "Update": "更新", + "Update Rules from Current Filter": "从当前筛选器更新规则", + "Update Version": "更新版本", + "Update with Current Settings": "使用当前设置更新", + "Upright": "垂直校正", + "Use Library Folder": "使用图库文件夹", + "Use camera-specific defaults": "使用相机专属默认值", + "Use the GPU for rendering": "使用 GPU 渲染", + "User Presets": "用户预设", + "Value": "值", + "Variance": "变化幅度", + "Version": "版本", + "Version name": "版本名称", + "Versions": "版本", + "Versions (Shift+V)": "版本 (Shift+V)", + "Vertical": "垂直", + "Vibrance": "鲜艳度", + "Videos": "视频", + "View": "视图", + "Vignette": "暗角", + "Vignetting": "暗角", + "Visualize Spots": "可视化斑点", + "Visualize Spots (A)": "可视化斑点 (A)", + "Visualize range": "显示范围", + "Vivid": "鲜艳", + "Watched folder": "监视的文件夹", + "Watermark": "水印", + "What's New": "新功能", + "When rated or hovered": "评级或悬停时", + "White Balance": "白平衡", + "White Balance Selector": "白平衡选择器", + "White Balance Selector (W)": "白平衡选择器 (W)", + "Whites": "白色色阶", + "Width": "宽度", + "Width (% of photo)": "宽度(照片百分比)", + "Width (px)": "宽度 (px)", + "Width × Height": "宽度 × 高度", + "Window": "窗口", + "XMP sidecars": "XMP 附属文件", + "Year": "年", + "Yellow": "黄色", + "Yellow Hue": "黄色色相", + "Yellow Luminance": "黄色明度", + "Yellow Saturation": "黄色饱和度", + "Yes": "是", + "Zoom": "缩放", + "Zoom 100%": "缩放 100%", + "Zoom In": "放大", + "Zoom Out": "缩小", + "Zoom to Fit": "适合窗口", + "and": "和", + "comma, separated": "逗号分隔", + "memory only": "仅内存", + "none": "无", + "not available here": "此处不可用", + "of the following rules:": "以下规则:", + "Effect": "效果", + "Image": "图像", + "Layer": "图层", + "Type": "类型", + "Save As…": "另存为…", + "Exit": "退出", + "New…": "新建…", + "New": "新建", + "Orientation": "方向", + "Layers": "图层", + "Properties": "属性", + "Brush Settings": "画笔设置", + "Options": "选项", + "Fit on Screen": "适合屏幕", + "Cut": "剪切", + "Paste": "粘贴", + "Deselect": "取消选择", + "Export As…": "导出为…", + "Search…": "搜索…", + "Theme": "主题", + "Pages": "页面", + "Preferences": "首选项", + "Preferences…": "首选项…", + "Interface language": "界面语言", + "Open…": "打开…", + "New blank PDF": "新建空白 PDF", + "Create PDF from file…": "从文件创建 PDF…", + "Create PDF from images…": "从图像创建 PDF…", + "Create PDF from clipboard": "从剪贴板创建 PDF", + "Combine files…": "合并文件…", + "Save as…": "另存为…", + "Close file": "关闭文件", + "Close all": "全部关闭", + "Revert": "恢复", + "Print…": "打印…", + "Document properties…": "文档属性…", + "Find…": "查找…", + "Advanced search…": "高级搜索…", + "Copy pages": "复制页面", + "Cut pages": "剪切页面", + "Paste pages": "粘贴页面", + "Fit visible": "适合可见区域", + "Marquee zoom": "框选缩放", + "Take a snapshot": "创建快照", + "Full screen mode": "全屏模式", + "Read mode": "阅读模式", + "Switch light / dark theme": "切换浅色/深色主题", + "Comments panel": "注释面板", + "Form fields panel": "表单字段面板", + "Clear form": "清除表单", + "Find tools and commands…": "查找工具和命令…", + "Actual size": "实际大小", + "Zoom to page level": "缩放至页面级别", + "Fit to width": "适合宽度", + "Display theme": "显示主题", + "Side panels": "侧面板", + "Text direction": "文字方向", + "Horizontal text": "横排文字", + "Vertical text": "竖排文字", + "Your library couldn't be opened": "无法打开图库", + "LightCraft couldn't find where to keep your library.": "LightCraft 找不到保存图库的位置。", + "LightCraft couldn't open the library at": "LightCraft 无法打开以下位置的图库:", + "Try Again": "重试", + "Choose Another Library…": "选择其他图库…", + "Continue Without Saving": "不保存继续", + "Quit": "退出", + "Temporary session — your library isn't open, and nothing you do here is saved.": "临时会话 — 图库未打开,此处所做的任何操作都不会保存。", + "Back Up Library…": "备份图库…", + "Restore Library from Backup…": "从备份恢复图库…" +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9e1f09252..d38980654 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -55,7 +55,7 @@ fn wrap(r: Result) -> Outcome { pub fn all_commands(app: &LightcraftApp) -> Value { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); - for (id, label, sc, menu) in crate::menus::UI_COMMANDS { + for (id, label, sc, menu) in crate::menus::ui_commands() { v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true})); } Value::Array(v) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0e2389a44..55f52d523 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -1,224 +1,418 @@ //! Presentation-only localization. Command ids, user names and catalog data remain unchanged. -use std::{cell::Cell, collections::BTreeMap, sync::OnceLock}; +//! +//! A language is one entry in [`language_table!`] below: its BCP-47 code, the endonym shown in the +//! Language menus, the ISO 15924 script its text needs (which picks the CJK font fallback), and its +//! message catalog (`locales/.json`, embedded at build time). Adding a language is that entry +//! plus its catalogs — every menu and settings surface is built from the table, so nothing else in +//! the UI changes. -use serde::{Deserialize, Serialize}; +use std::{cell::RefCell, collections::BTreeMap, sync::OnceLock}; -include!(concat!(env!("OUT_DIR"), "/ja-formats.rs")); +use serde::{Deserialize, Deserializer, Serialize, Serializer}; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub enum Language { - #[default] - #[serde(rename = "en")] - En, - #[serde(rename = "ja")] - Ja, +/// The language table. +/// +/// The source language comes first and alone: it is the text the UI is authored in, which every +/// other language translates. `name` is written in the language itself (an endonym, so a user who +/// cannot read the current UI language can still find their own). +macro_rules! language_table { + ($source:ident, $source_code:literal; $( $variant:ident, $code:literal, $name:literal, $script:literal, $catalog:expr );* $(;)?) => { + /// A UI language. + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] + pub enum Locale { + /// The language the UI is written in. + #[default] + $source, + $($variant,)* + } + + impl Locale { + /// Every language, in menu order (the source language first). + pub const ALL: &'static [Self] = &[Self::$source, $(Self::$variant,)*]; + + pub const fn code(self) -> &'static str { + match self { Self::$source => $source_code, $(Self::$variant => $code,)* } + } + + /// The language's own name, as shown in the Language menus. + pub const fn name(self) -> &'static str { + match self { Self::$source => "English", $(Self::$variant => $name,)* } + } + + /// The ISO 15924 script this language is written in (`"Latn"`, `"Jpan"`, `"Hans"`…): + /// the craft-fonts faces for it are the ones this language needs. + pub const fn script(self) -> &'static str { + match self { Self::$source => "Latn", $(Self::$variant => $script,)* } + } + + /// The language's embedded catalog, parsed once. The source language has none: its + /// lookups return the source text itself. + fn catalog(self) -> &'static BTreeMap { + static CATALOGS: OnceLock>> = OnceLock::new(); + static EMPTY: OnceLock> = OnceLock::new(); + // A malformed catalog logs and comes back empty: a broken translation degrades to + // English text, it never takes the app down. + let parse = |catalog: Option<&'static str>| match catalog { + Some(json) => match serde_json::from_str(json) { + Ok(messages) => messages, + Err(error) => { + log::error!("Invalid message catalog for {}: {error}", self.code()); + BTreeMap::new() + } + }, + None => BTreeMap::new(), + }; + CATALOGS + .get_or_init(|| { + Locale::ALL + .iter() + .map(|language| { + let catalog = match *language { $(Self::$variant => Some($catalog),)* _ => None }; + (*language, parse(catalog)) + }) + .collect() + }) + .get(&self) + .unwrap_or_else(|| EMPTY.get_or_init(BTreeMap::new)) + } + + pub fn parse(code: &str) -> Option { + match code { $source_code => Some(Self::$source), $($code => Some(Self::$variant),)* _ => None } + } + + /// Lenient lookup: `zh`, `zh-CN`, `zh_Hans`, `en-US`, `ja_JP.UTF-8`… all reach a + /// shipped language. A region or script the table does not ship falls back to the + /// language itself, so a user is never left with no language at all. + pub fn parse_tag(tag: &str) -> Option { + let tag = tag.split(['.', '@']).next().unwrap_or(tag).replace('_', "-"); + let lower = tag.to_ascii_lowercase(); + if let Some(exact) = Self::ALL.iter().find(|language| language.code().eq_ignore_ascii_case(&lower)) { + return Some(*exact); + } + let mut parts = lower.split('-'); + let language = parts.next()?; + // `zh-Hans-CN`, `zh-CN` and `zh` all name the same base language. + let script = parts.next().and_then(|part| match part { + "hans" | "cn" | "sg" => Some("Hans"), + "hant" | "tw" | "hk" | "mo" => Some("Hant"), + _ => None, + }); + Self::ALL + .iter() + .find(|candidate| { + let base = candidate.code().split('-').next().unwrap_or_default(); + base == language && script.is_none_or(|script| candidate.script() == script) + }) + .copied() + } + + pub fn tr(self, source: &str) -> &str { + tr_in(self, source) + } + } + }; } -impl Language { - pub const ALL: [Self; 2] = [Self::En, Self::Ja]; - pub fn name(self) -> &'static str { - match self { - Self::En => "English", - Self::Ja => "日本語", - } +// The source language, then one entry per translation: code, endonym, ISO 15924 script, embedded +// catalog (`locales/.json`). +language_table! { + En, "en"; + ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json"); + Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json"); +} + +// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a +// language can be renamed in code without invalidating anyone's settings. +impl Serialize for Locale { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.code()) } - pub fn parse(code: &str) -> Option { - match code { - "en" => Some(Self::En), - "ja" => Some(Self::Ja), - _ => None, - } - } - pub fn tr(self, source: &str) -> &str { - if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source } +} + +impl<'de> Deserialize<'de> for Locale { + fn deserialize>(deserializer: D) -> Result { + let code = String::deserialize(deserializer)?; + Locale::parse(&code).or_else(|| Locale::parse_tag(&code)).ok_or_else(|| serde::de::Error::custom(format!("unknown language {code:?}"))) } } thread_local! { - static LANGUAGE: Cell = const { Cell::new(Language::En) }; + static LOCALE: std::cell::Cell = const { std::cell::Cell::new(Locale::En) }; } -pub fn default_language() -> Language { - if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En } +/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs. +pub fn default_language() -> Locale { + std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En) } -pub fn set_language(language: Language) { - LANGUAGE.with(|value| value.set(language)); +pub fn set_language(language: Locale) { + LOCALE.with(|value| value.set(language)); } -pub fn is_japanese() -> bool { - LANGUAGE.with(|value| value.get() == Language::Ja) +pub fn language() -> Locale { + LOCALE.with(std::cell::Cell::get) } -fn japanese() -> &'static BTreeMap { - static MESSAGES: OnceLock> = OnceLock::new(); - MESSAGES.get_or_init(|| { - serde_json::from_str(include_str!("../locales/ja.json")).unwrap_or_else(|error| { - log::error!("Invalid Japanese message catalog: {error}"); - BTreeMap::new() - }) - }) +/// Whether the active language needs the craft-fonts CJK faces (Inter alone has no CJK glyphs): +/// the native menu bar uses it to pick a text style. +pub fn uses_cjk_fonts() -> bool { + language().script() != "Latn" } /// Translate a built-in display label, preserving unknown labels verbatim. /// Never call this on editable user text, filenames or command identifiers. pub fn tr(source: &str) -> &str { - if is_japanese() { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source } + tr_in(language(), source) } +/// Catalog values are `'static` (they live in the embedded file), so a translation can be handed +/// out for as long as the `'static` key it was looked up by. +fn tr_in(language: Locale, source: &str) -> &str { + if language.catalog().is_empty() { + return source; + } + thread_local! { + static VERBATIM: RefCell>> = const { RefCell::new(BTreeMap::new()) }; + } + VERBATIM.with_borrow_mut(|cache| { + let verbatim = cache.entry(language).or_insert_with(|| catalog_verbatim(language)); + verbatim.get(source).copied().unwrap_or(source) + }) +} + +/// One language's catalog keyed for lookup, with values that outlive the catalog's own borrow. +fn catalog_verbatim(language: Locale) -> BTreeMap { + language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() +} + +/// Whether the language translates anything at all (the `tr_format!` fast path). +pub fn has_translations(language: Locale) -> bool { + !language.catalog().is_empty() +} + +/// Translate a format string with runtime values, as `tr_format!` calls it. +/// +/// `english` is the source text already formatted with the call site's values, and `values` names +/// those values so the translation can place them. A message with no translation, or a translation +/// without placeholders, keeps `english` as it is. +pub fn format_message(source: &str, english: String, values: &[(&str, String)]) -> String { + let Some(translated) = language().translated_format(source) else { + return english; + }; + if !translated.contains('{') { + return translated.to_string(); + } + let mut out = String::with_capacity(translated.len()); + let mut rest = translated; + while let Some(start) = rest.find('{') { + let Some(end) = rest[start..].find('}') else { break }; + let field = &rest[start + 1..start + end]; + let (name, _) = field.split_once(':').unwrap_or((field, "")); + out.push_str(&rest[..start]); + // The catalogs spell every value out by name; anything else (a `{}` that only the English + // text used, or a name no call site passes) is left exactly as the translator wrote it. + match values.iter().find(|(key, _)| *key == name) { + Some((_, value)) => out.push_str(value), + None => out.push_str(&rest[start..start + end + 1]), + } + rest = &rest[start + end + 1..]; + } + out.push_str(rest); + out +} + +// `tr_format!` (one arm per language) and the translated-format lookup are generated by +// `build.rs`; `Locale` is declared above, so the generated `impl Locale` has it in scope. +include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); + #[cfg(test)] mod tests { use super::*; #[test] - fn catalog_is_valid_and_contains_core_workflows() { - let messages: BTreeMap = serde_json::from_str(include_str!("../locales/ja.json")).unwrap(); - for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] { - assert!(messages.get(key).is_some_and(|value| !value.is_empty() && value != key), "{key}"); + fn every_language_has_a_catalog_covering_the_source() { + assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); + for language in Locale::ALL { + assert!(!language.name().is_empty(), "{language:?} has a name"); + assert!(!language.script().is_empty(), "{language:?} has a script"); + assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code"); + if *language == Locale::En { + continue; + } + let messages = language.catalog(); + assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code()); + for (key, value) in messages { + assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}"); + } } } + /// The catalogs of the translations agree: same keys, same format placeholders. A source string + /// added to one language but forgotten in another fails here. #[test] - fn language_switches_and_unknown_text_survives() { - set_language(Language::Ja); - assert_eq!(tr("Exposure"), "露出"); - assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); - assert_eq!(tr("develop.set"), "develop.set"); - assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); - assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); - assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー"); - set_language(Language::En); - assert_eq!(tr("Exposure"), "Exposure"); + fn catalogs_agree_on_keys_and_placeholders() { + let fields = |text: &str| { + let mut out: Vec = Vec::new(); + let mut rest = text; + while let Some(start) = rest.find('{') { + let Some(end) = rest[start..].find('}') else { break }; + out.push(rest[start + 1..start + end].split(':').next().unwrap_or("").to_string()); + rest = &rest[start + end + 1..]; + } + out.sort(); + out + }; + let translated: Vec = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect(); + let Some((first, rest)) = translated.split_first() else { return }; + for language in rest { + let (a, b) = (first.catalog(), language.catalog()); + for (key, value) in a { + let Some(other) = b.get(key) else { + panic!("{language:?} is missing a translation for {key:?}"); + }; + assert_eq!(fields(key), fields(value), "{first:?}: {key:?} -> {value:?}"); + assert_eq!(fields(key), fields(other), "{language:?}: {key:?} -> {other:?}"); + } + for key in b.keys() { + assert!(a.contains_key(key), "{language:?} translates {key:?}, which {first:?} does not"); + } + } + } + + /// Locale tags are matched leniently, so a system locale reaches a shipped language. + #[test] + fn locale_tags_normalize() { + assert_eq!(Locale::parse_tag("en"), Some(Locale::En)); + assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En)); + assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja)); + assert_eq!(Locale::parse_tag("de"), None); } #[test] - fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() { - set_language(Language::Ja); - assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12枚"); + fn switching_language_translates_and_restores() { + set_language(Locale::ZhHans); + assert_eq!(tr("Exposure"), "曝光"); + assert_eq!(tr("Settings"), "设置"); + // Untranslated and data-like text passes through untouched. + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + assert_eq!(tr("A string no catalog has"), "A string no catalog has"); + set_language(Locale::Ja); + assert_eq!(tr("Exposure"), "露出"); + set_language(Locale::En); + assert_eq!(tr("Exposure"), "Exposure"); + } + + /// Format strings carry their values in every language, and a language without a translation + /// falls back to the English format rather than dropping the values. + #[test] + fn formats_render_in_every_language() { + // The English source is what `format!` checks at the call site; each translation then places + // the same values by name (`{n}`, `{total}`) or by position (`{_1}`, `{_2}`). + set_language(Locale::En); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); + set_language(Locale::ZhHans); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片"); + set_language(Locale::Ja); + assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました"); assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました"); - set_language(Language::En); - assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 photos"); + set_language(Locale::En); + // A format no catalog carries keeps its values, and one with no values keeps its text. + assert_eq!(tr_format!("{n} uncatalogued {thing}", n = 3, thing = "things"), "3 uncatalogued things"); + assert_eq!(tr_format!("Activity"), "Activity"); + } + + /// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation + /// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK + /// catalogs are skipped (the same degradation the Japanese UI has always had). + #[test] + fn catalog_characters_are_paintable() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + for language in Locale::ALL { + let messages = language.catalog(); + if messages.is_empty() { + continue; + } + // The faces this language's script needs; a language the craft-fonts input does not + // cover (a translation added ahead of its font) is reported, not failed. + let mut faces: Vec<&str> = + lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.covers(language.script())).map(|font| font.family).collect(); + faces.dedup(); + if faces.is_empty() { + eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + continue; + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family.clone()); + let mut missing: Vec = messages + .values() + .flat_map(|message| message.chars()) + .filter(|ch| !ch.is_whitespace() && !fonts.has_glyph(&font, *ch)) + .collect(); + missing.sort_unstable(); + missing.dedup(); + assert!( + missing.is_empty(), + "{} in {family:?} (faces: {faces:?}) lack glyphs: {}", + language.code(), + missing.iter().collect::() + ); + } + }); + } + } + + /// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a + /// translation must not carry a symbol no installed font has (egui's defaults cover the rest). + #[test] + fn interface_symbols_are_paintable() { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + // ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07). + ctx.fonts_mut(|fonts| { + for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] { + let font = egui::FontId::new(13.0, egui::FontFamily::Proportional); + assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font"); + } + }); + } + + /// The language menu covers every language, and a language's own command selects it. + #[test] + fn language_commands_cover_every_language() { + let commands = [("app.language.english", Locale::En), ("app.language.zh-hans", Locale::ZhHans), ("app.language.japanese", Locale::Ja)]; + // One command per language, and every command reachable from the menu table. + assert_eq!(commands.len(), Locale::ALL.len()); + for (id, language) in commands { + assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}"); + assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu"); + } + assert_eq!(crate::menus::language_from_command("app.language.klingon"), None); + assert_eq!(crate::menus::language_from_command("view.detail"), None); } #[test] fn preferences_round_trip_and_old_settings_remain_readable() { let old: crate::state::UiState = serde_json::from_str("{}").unwrap(); - assert_eq!(old.language, Language::En); - let settings = crate::state::UiState { language: Language::Ja, ..old }; + assert_eq!(old.language, Locale::En); + // Settings written before the language list grew still load. + let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap(); + assert_eq!(legacy.language, Locale::Ja); + let settings = crate::state::UiState { language: Locale::ZhHans, ..old }; let saved = serde_json::to_string(&settings).unwrap(); let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); - assert_eq!(restored.language, Language::Ja); - } - - #[test] - fn japanese_is_painted_and_both_font_weights_cover_the_catalog() { - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.language = Language::Ja; - app.ui.left_panel = true; - let mut text = String::new(); - fn collect(shape: &egui::epaint::Shape, text: &mut String) { - match shape { - egui::epaint::Shape::Text(shape) => { - text.push_str(&shape.galley.job.text); - text.push('\n'); - } - egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), - _ => {} - } - } - for frame in 0..4 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - // This assertion inspects shapes without a renderer; discard texture uploads explicitly. - out.textures_delta.clear(); - text.clear(); - for shape in out.shapes { - collect(&shape.shape, &mut text); - } - } - assert!(text.contains("マイフォト"), "{text}"); - assert!(text.contains("すべての写真"), "{text}"); - if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() { - eprintln!("skipped glyph coverage: built without CRAFT_FONTS_DIR, so there is no Japanese UI font"); - } else { - ctx.fonts_mut(|fonts| { - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - for message in japanese().values() { - for ch in message.chars().filter(|ch| !ch.is_whitespace()) { - assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}"); - } - } - } - }); - } - // Locale affects presentation only: command ids remain the same. - let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); - let japanese_ids = ids(&app); - set_language(Language::En); - assert_eq!(japanese_ids, ids(&app)); - } - /// The UI families, after one frame so the font definitions are loaded. - fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { - let ctx = egui::Context::default(); - ctx.set_fonts(crate::theme::font_definitions(craft)); - let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); - out.textures_delta.clear(); - ctx - } - - fn ui_families() -> [egui::FontFamily; 3] { - [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] - } - - /// Built with craft-fonts, Japanese renders with real glyphs (no tofu) in every UI family. - #[test] - fn craft_fonts_render_japanese_in_the_ui() { - if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() { - eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese UI font"); - return; - } - let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); - ctx.fonts_mut(|fonts| { - for family in ui_families() { - let font = egui::FontId::new(13.0, family); - for ch in "日本語の文字".chars() { - assert!(fonts.has_glyph(&font, ch), "{ch} in {font:?}"); - } - let galley = fonts.layout_no_wrap("日本語の文字".into(), font.clone(), egui::Color32::WHITE); - assert!(galley.size().x > 13.0 * 5.0, "{font:?}: six full-width glyphs, {:?}", galley.size()); - } - }); - } - - /// Built without craft-fonts, the UI (in Japanese, too) still installs its fonts and runs; - /// Latin text keeps Inter. - #[test] - fn the_ui_works_without_craft_fonts() { - let ctx = fonts_ctx(&[]); - ctx.fonts_mut(|fonts| { - // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's - // replacement-glyph face, which there is Hack, the face that draws Latin.) - for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { - let font = egui::FontId::new(13.0, family); - assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); - } - }); - let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); - app.ui.language = Language::Ja; - for frame in 0..3 { - let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); - let mut out = ctx.run_ui(input, |ui| { - app.logic(ui.ctx()); - app.ui(ui); - }); - out.textures_delta.clear(); - } - set_language(Language::En); + assert_eq!(restored.language, Locale::ZhHans); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8addc61b5..9f6889199 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1,7 +1,7 @@ //! LightCraft's egui frontend: a Lightroom-style UI over `lightcraft-engine`. //! //! The UI is thin: every action goes through [`LightcraftApp::run`], which handles UI commands -//! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine. +//! (views, panels, zoom — see [`menus::ui_commands`]) and forwards everything else to the engine. //! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]). #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e0..9352a9c1b 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -1,5 +1,5 @@ //! The menu bar model: File, Edit, View, Photo, Window, Help, generated from the command registry -//! (engine commands with menu paths + [`crate::menus::UI_COMMANDS`]) with live labels, shortcuts, +//! (engine commands with menu paths + [`crate::menus::ui_commands`]) with live labels, shortcuts, //! enabled and checked state. //! //! One model drives every menu surface: the native macOS menu bar (built by the desktop host), the @@ -270,8 +270,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { let u = &app.ui; let panel = |p: RightPanel| Some(u.right == p); match id { - "app.language.english" => Some(u.language == crate::i18n::Language::En), - "app.language.japanese" => Some(u.language == crate::i18n::Language::Ja), + // Every language's command is checked when it is the active one. + _ if crate::menus::language_from_command(id).is_some() => Some(crate::menus::language_from_command(id) == Some(u.language)), "develop.autoSync" => Some(app.session.auto_sync), "view.photoCounts" => Some(u.show_counts), "view.secondWindow" => Some(u.second_window), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5d838b759..92910324d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -10,6 +10,31 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; /// (id, label, shortcut, menu path) pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str); +/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language +/// added to the table shows up in the menu (and in the control channel's command list) by itself. +const LANGUAGE_COMMANDS: &[UiCommand] = &[ + ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), + ("app.language.zh-hans", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), + ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), +]; + +/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from +/// this file, so an id listed in `docs/parity.md` is checked wherever it is declared. +pub fn ui_commands() -> impl Iterator { + LANGUAGE_COMMANDS.iter().chain(UI_COMMANDS) +} + +/// The language a Language-menu command selects, if the id is one. The engine and the UI both go +/// through here, so the menu, the settings row and the control channel agree on the mapping. +pub fn language_from_command(id: &str) -> Option { + match id { + "app.language.english" => Some(crate::i18n::Locale::En), + "app.language.zh-hans" => Some(crate::i18n::Locale::ZhHans), + "app.language.japanese" => Some(crate::i18n::Locale::Ja), + _ => None, + } +} + pub const UI_COMMANDS: &[UiCommand] = &[ ("app.language.english", "English", None, "Edit>Language"), ("app.language.japanese", "日本語", None, "Edit>Language"), @@ -206,8 +231,8 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { - if matches!(id, "app.language.english" | "app.language.japanese") { - app.ui.language = if id == "app.language.japanese" { crate::i18n::Language::Ja } else { crate::i18n::Language::En }; + if let Some(language) = language_from_command(id) { + app.ui.language = language; return Some(Ok(json!(app.ui.language))); } let ctx = egui::Context::default(); @@ -1251,8 +1276,7 @@ pub struct MenuEntry { /// The flattened menu model (UI commands + engine commands with menu paths). pub fn menu_entries(app: &LightcraftApp) -> Vec { - let mut v: Vec = UI_COMMANDS - .iter() + let mut v: Vec = ui_commands() .filter(|c| !c.3.is_empty()) .map(|(id, label, sc, m)| MenuEntry { id: id.to_string(), diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fe8b04781..1ea18d0ed 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -733,7 +733,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Shortcuts => { egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { egui::Grid::new("shortcuts").striped(true).show(ui, |ui| { - for (id, label, sc, _) in crate::menus::UI_COMMANDS { + for (id, label, sc, _) in crate::menus::ui_commands() { if let Some(sc) = sc { ui.label(crate::i18n::tr(label)); ui.label(*sc); @@ -750,8 +750,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } for (sc, id, _) in crate::shortcuts::ALIASES { - let label = crate::menus::UI_COMMANDS - .iter() + let label = crate::menus::ui_commands() .find(|c| c.0 == *id) .map(|c| c.1) .or_else(|| lightcraft_engine::find_command(id).map(|c| c.label)) diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index a380090e1..e191668d9 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -91,7 +91,8 @@ fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Language"), |ui| { - choices(ui, "settingsLanguage", &[(crate::i18n::Language::Ja, "日本語"), (crate::i18n::Language::En, "English")], &mut app.ui.language); + let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect(); + choices(ui, "settingsLanguage", &languages, &mut app.ui.language); crate::i18n::set_language(app.ui.language); }); heading(ui, t, crate::i18n::tr("Library")); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 07665dad0..c5d2ce16a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -89,7 +89,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); ctx.input(|i| { let mut ui_keys = Vec::new(); - for (id, _, sc, _) in crate::menus::UI_COMMANDS { + for (id, _, sc, _) in crate::menus::ui_commands() { if let Some((m, k)) = sc.and_then(parse) { ui_keys.push((m, k)); if !native(sc.unwrap_or_default()) && matches(i, m, k) { @@ -286,7 +286,7 @@ mod tests { assert_eq!(parse("Right").unwrap().1, Key::ArrowRight); assert_eq!(parse("\\").unwrap().1, Key::Backslash); // every declared shortcut parses - for (id, _, sc, _) in crate::menus::UI_COMMANDS { + for (id, _, sc, _) in crate::menus::ui_commands() { if let Some(sc) = sc { assert!(parse(sc).is_some(), "{id}: {sc}"); } @@ -300,7 +300,7 @@ mod tests { #[test] fn aliases_parse_and_target_existing_commands() { - let ui: Vec<&str> = crate::menus::UI_COMMANDS.iter().map(|c| c.0).collect(); + let ui: Vec<&str> = crate::menus::ui_commands().map(|c| c.0).collect(); for (sc, id, params) in ALIASES { assert!(parse(sc).is_some(), "{id}: {sc}"); assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}"); @@ -315,7 +315,7 @@ mod tests { #[test] fn no_conflicting_bindings() { let mut ui: Vec<((Modifiers, Key), String)> = Vec::new(); - for (id, _, sc, _) in crate::menus::UI_COMMANDS { + for (id, _, sc, _) in crate::menus::ui_commands() { if let Some(k) = sc.and_then(parse) { ui.push((k, id.to_string())); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1a4446e3a..f9d8c921a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -201,7 +201,7 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0; #[serde(default, rename_all = "camelCase")] pub struct UiState { #[serde(default = "crate::i18n::default_language")] - pub language: crate::i18n::Language, + pub language: crate::i18n::Locale, /// The Build Previews run last announced (its identity, finished?). #[serde(skip)] pub preview_build_seen: Option<(usize, bool)>, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b5db692d5..c77fb0a2f 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -110,31 +110,35 @@ pub fn install_fonts(ctx: &egui::Context) { ctx.set_fonts(font_definitions(lightcraft_engine::CRAFT_FONTS)); } -/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts Japanese faces as -/// the last fallback of every family (BIZ UDPGothic first; Bold first for the semibold family). -/// Without craft-fonts (`craft` empty) Japanese text has no glyphs and shows as boxes. +/// Inter (bundled) for Latin text, egui's default fonts, then the craft-fonts CJK faces as the last +/// fallback of every family — the active language's own script first, so shared Han characters keep +/// that language's forms. Without craft-fonts (`craft` empty) CJK text has no glyphs and shows as +/// boxes. pub fn font_definitions(craft: &'static [lightcraft_engine::CraftFont]) -> FontDefinitions { let mut fonts = FontDefinitions::default(); fonts.font_data.insert("Inter".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); fonts.font_data.insert("Inter-SemiBold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")))); - let japanese: Vec<_> = lightcraft_engine::fonts::japanese(craft).collect(); - for f in &japanese { - fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes))); - } // Craft-fonts faces in preference order for a family drawn in `style`. let fallback = |style: &str| { - let mut faces = japanese.clone(); - faces.sort_by_key(|f| (f.family != "BIZ UDPGothic", f.style != style, f.family.contains("Mincho"))); - faces.into_iter().map(craft_font_name).collect::>() + lightcraft_engine::fonts::cjk_fallback(craft, crate::i18n::language().script(), style) + .into_iter() + .map(craft_font_name) + .collect::>() }; + let (regular, bold) = (fallback("Regular"), fallback("Bold")); + for name in regular.iter().chain(bold.iter()) { + if let Some(font) = craft.iter().find(|font| &craft_font_name(font) == name) { + fonts.font_data.insert(name.clone(), Arc::new(FontData::from_static(font.bytes))); + } + } let defaults: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); let mut prop = vec!["Inter".to_string()]; prop.extend(defaults.iter().cloned()); - prop.extend(fallback("Regular")); + prop.extend(regular); fonts.families.insert(FontFamily::Proportional, prop); let mut semi = vec!["Inter-SemiBold".to_string()]; semi.extend(defaults); - semi.extend(fallback("Bold")); + semi.extend(bold); fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi); fonts.families.entry(FontFamily::Monospace).or_default().extend(fallback("Regular")); fonts diff --git a/docs/localization-ja.md b/docs/localization-ja.md index 4d1aa6277..9dfa998e8 100644 --- a/docs/localization-ja.md +++ b/docs/localization-ja.md @@ -1,9 +1,9 @@ # LightCraft 日本語表示 -LightCraft は日本語と英語(English)の表示に対応しています。 +LightCraft は日本語・簡体字中国語・英語(English)の表示に対応しています。 言語設定は `ui.json` の `language` に保存します。 -- 「編集 → 言語」または「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。 +- 「編集 → 言語」または「設定 → 一般 → 言語」で切り替えます。選択は次回起動にも引き継ぎます。 - メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。 - 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを使います。日本語フォントはこのリポジトリではなく [storytold/craft-fonts](https://github.com/storytold/craft-fonts) にあり、任意のビルド入力 @@ -11,13 +11,18 @@ LightCraft は日本語と英語(English)の表示に対応しています 指定しない場合も動作しますが、日本語の文字は表示されません。 - 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。 - 翻訳されていない技術的なエラー、追加情報、リリースノートは英語で表示します。 +- 中国語表示は [`localization-zh-hans.md`](localization-zh-hans.md) を参照してください。 +- 別の言語の追加方法は [`localization.md`](localization.md) を参照してください。 ## 翻訳の保守 静的な表示文言は `crates/ui-egui/locales/ja.json`、可変値を含む表示文言は `crates/ui-egui/locales/ja-formats.json` にあります。英文をキーにして翻訳を管理します。 -可変文言の両言語はビルド時にRustのフォーマット検査を受けます。 -英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。 + +可変文言は、呼び出し側の英語の書式を `format!` がそのまま検査し、その値を翻訳側が +名前(`{n}`、`{total}`)または位置(`_1`、`_2` …)で参照します。英語の単複数語尾のように +中国語・日本語で不要な値は、**単に参照しないだけ**です(`{:.0}` のような空の引数は不要)。 +両言語の訳文はビルド時に `format!` の検査を受けるため、存在しない名前を書くとコンパイルエラーになります。 `LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。 表示・フォント・言語の切り替え・設定の保存・コマンドIDの保持は diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md new file mode 100644 index 000000000..f643c2aa8 --- /dev/null +++ b/docs/localization-zh-hans.md @@ -0,0 +1,36 @@ +# LightCraft 简体中文界面 + +LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 `ui.json` 的 `language` 字段中。 + +- 通过「编辑 → 语言」或「设置 → 一般 → 语言」切换,选择会保留到下次启动。 +- 已翻译的范围:菜单、照片编辑、蒙版、裁剪、设置、导入、导出,以及主要的进度提示。 +- 正文字重与半粗体都使用 SIL OFL 的 **Noto Sans CJK SC**(简体中文)与 BIZ UDPGothic(日语)。 + 这些字体不在本仓库,而在 [storytold/craft-fonts](https://github.com/storytold/craft-fonts); + 使用可选的构建输入 `CRAFT_FONTS_DIR=../craft-fonts` 时(所有正式发布版)会内嵌进来。 + 不指定该输入时程序照常构建运行,但中文与日文文字没有字形,会显示为方框。 +- 翻译只发生在显示层:命令 ID、照片文件名、用户输入的元数据都不做改动。 +- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。 +- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框 + (单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。 + 原生桌面版不受影响。 +- 日期分组标题(如 `Sunday, 20 September 2026`)沿用英文格式,尚未本地化。 + +## 翻译的维护 + +静态界面文字在 `crates/ui-egui/locales/zh-hans.json`,含运行时数值的文字在 +`crates/ui-egui/locales/zh-hans-formats.json`。两者都以英文原文为键。 + +含数值的文字遵循以下约定: + +- 调用点的英文原文由 `format!` 检查(占位符写错会编译失败),翻译再按名字或位置引用同一个值。 +- **按名字**:`{n}`、`{label}`、`{total}` 等与英文原文中的名字一致。 +- **按位置**:`_1`、`_2`、`_3` … 对应调用点参数的先后顺序(从 1 开始),用于调整语序, + 例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{_2}”`。 +- 英文用来表示单复数的那个参数(`"s"` / `""`)在中文里**不需要写**,直接不引用即可; + 不需要 `{:.0}` 之类的空占位符。 +- 译文里的占位符同样会被 `format!` 检查:写了不存在的名字就编译不过。 + +`LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。 + +翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由 +`cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。 diff --git a/docs/localization.md b/docs/localization.md new file mode 100644 index 000000000..da3ff77df --- /dev/null +++ b/docs/localization.md @@ -0,0 +1,79 @@ +# Localization + +The interface ships in the language it is written in (English) plus every language in the table in +`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`) and Japanese +(`ja`). This file is the reference for **adding or maintaining a language**; the per-language notes +are in [`localization-zh-hans.md`](localization-zh-hans.md) and [`localization-ja.md`](localization-ja.md). + +## Adding a language + +1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to + `locales/.json` and `locales/-formats.json`, then translate the values. Keys are the + English source strings and must stay byte-for-byte identical — `cargo test -p lightcraft-ui-egui + i18n::tests` fails on a missing, extra or empty entry. +2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`: + + ```rust + language_table! { + En, "en"; + ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json"); + Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json"); + } + ``` + + The fields are the BCP-47 code (also the settings-file value), the **endonym** shown in the + Language menu, the ISO 15924 script (`Latn`, `Jpan`, `Hans`, `Hant`, `Kore`…), and the embedded + catalog. `build.rs` picks up `-formats.json` by name, so the macro and the format lookup + follow automatically — nothing else in the UI needs to change. The menus, the settings row, the + keyboard-shortcut sheet and the control channel all read the table. +3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a + language whose script has no face in the build shows boxes (the test says so instead of failing). + Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a + missing face is added there, not worked around in this repository. +4. Run `cargo xtask ci` (it includes `fmt`, `clippy`, the tests, `parity` and the wasm check). + +## How a message is found + +`tr("Exposure")` looks the English label up in the active language's catalog and returns the source +text unchanged when there is no entry; `tr_format!` handles text with runtime values. Both are +presentation-only: command ids, file names and user-entered metadata are never translated. + +`tr_format!` is generated by `crates/ui-egui/build.rs`. It formats the **English source at the call +site**, which is what gives `format!` a literal to check against that call site's own arguments, and +it also names those values so a translation can place them: + +```rust +tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album, n = n) +``` + +- A translation refers to a value **by name** exactly as the English source spells it (`{n}`), or + **by position** as `_1`, `_2`, … in call-site order. Reordering is fine: + `"已将 {n} 张照片添加到“{_2}”"`. +- The English plural suffix (`"s"` / `""`) is simply not referenced in languages without plurals. +- Every translation is placeholder-checked at compile time: an unknown name, or a spec that does not + match its value, fails the build rather than printing `{}` at runtime. +- A source string with no translation falls back to the English text, values included. + +## Testing and looking at it + +```sh +cargo test -p lightcraft-ui-egui i18n::tests # catalogs, keys, placeholders, commands +CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage +``` + +The glyph tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then). +To look at a language, render it headless: + +```sh +LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot --demo --script tour.jsonl -o out.png --size 1600x1000 +``` + +or, with the app running (`--control 7980`), run the language command +(`engine.execute {"command": "app.language.zh-hans"}`) and take a screenshot. + +## Language codes and settings + +The settings file stores the BCP-47 code, never the Rust variant name, so a language can be renamed +in code without invalidating anyone's saved preference. `LIGHTCRAFT_LANGUAGE` accepts what a system +locale looks like (`zh_Hans`, `zh-CN`, `en_US`, `ja_JP.UTF-8`) and falls back to the base language +when a region has no dedicated entry. diff --git a/docs/parity.md b/docs/parity.md index fc94d08a7..d1514c9f5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 15 | 3 | 2 | 1 | 8/8 (100%) | 6/8 (75%) | +| X. Cross-cutting behaviours (BEHAV) | 16 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 31 | 9 | 40 | 9 | — | 21/22 (95%) | -| **Total** | 387 | 29 | 91 | 37 | 194/200 (97%) | 139/149 (93%) | +| **Total** | 388 | 30 | 90 | 37 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.2% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.3%** of 508 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.9% of 159. ## Top gaps @@ -503,7 +503,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization-ja.md` | Japanese/English UI with persisted language preference and Japanese regular/bold fonts; menus, edit, crop/masks, settings, import/export and primary progress messages. Technical errors/release notes and other locales remain untranslated. | +| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.zh-hans`, `cmd:app.language.japanese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese and Japanese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Technical errors/release notes and date grouping headers remain English, and the web build embeds only the Japanese face. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index caa3932a9..3e59a2013 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -137,12 +137,17 @@ pub fn parse(md: &str) -> Doc { doc } -/// Ids in `UI_COMMANDS` (`("view.detail", "Detail", Some("D"), "View"),` lines). +/// Ids in the menu tables (`("view.detail", "Detail", Some("D"), "View"),` lines): the language +/// commands live in their own table, which the Language menu builds from the i18n language list. pub fn ui_command_ids(menus_rs: &str) -> Vec { - let Some(start) = menus_rs.find("pub const UI_COMMANDS") else { return Vec::new() }; - let body = &menus_rs[start..]; - let body = &body[..body.find("];").unwrap_or(body.len())]; - body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string())).collect() + let mut ids = Vec::new(); + for table in ["pub const UI_COMMANDS", "pub const LANGUAGE_COMMANDS"] { + let Some(start) = menus_rs.find(table) else { continue }; + let body = &menus_rs[start..]; + let body = &body[..body.find("];").unwrap_or(body.len())]; + ids.extend(body.lines().filter_map(|l| l.trim().strip_prefix("(\"")).filter_map(|l| l.split_once('"').map(|(id, _)| id.to_string()))); + } + ids } fn known(set: &BTreeSet, id: &str) -> bool { From 489f78ba6967067ad68f6471b39a6708f1a50213 Mon Sep 17 00:00:00 2001 From: Mohammed Munzir Date: Wed, 7 Oct 2026 10:40:55 +0300 Subject: [PATCH 19/30] CLI: the connect hint names the port it actually tried (#167) run --connect ADDR could not reach the app and always advised lightcraft --control 7980, whatever port ADDR named, so following the hint left the command connecting elsewhere. The hint now takes the port from ADDR itself, with an end-to-end test on a refused connection. --- apps/lightcraft-cli/src/main.rs | 68 +++++++++++++++++--------------- apps/lightcraft-cli/tests/cli.rs | 10 +++++ 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 9bf0e3015..c1312081d 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -416,39 +416,39 @@ fn run(args: &[String]) -> Result<(), String> { if steps.is_empty() { return Err("run: no command given (see `lightcraft-cli commands`)".into()); } - let mut backend: Box = match connect { - Some(addr) => { - if demo || library.is_some() || !imports.is_empty() { - return Err("--demo, --library and --import apply to headless mode only".into()); - } - Box::new( - Remote::connect(&addr) - .map_err(|e| format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control 7980`"))?, - ) - } - None => { - let mut h = match &library { - Some(dir) => { - let mut h = Headless::default(); - h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?; - h - } - None if demo => Headless::demo(), - None => Headless::default(), - }; - if !imports.is_empty() { - let paths = expand_paths(&imports); - let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; - // what follows acts on the imported photos (already-known files are reported as duplicates) - let mut ids: Vec = r["imported"].as_array().cloned().unwrap_or_default(); - ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned())); - if let Some(first) = ids.first().cloned() { - h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?; + let mut backend: Box = + match connect { + Some(addr) => { + if demo || library.is_some() || !imports.is_empty() { + return Err("--demo, --library and --import apply to headless mode only".into()); } + Box::new(Remote::connect(&addr).map_err(|e| { + format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr)) + })?) } - Box::new(h) - } - }; + None => { + let mut h = match &library { + Some(dir) => { + let mut h = Headless::default(); + h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?; + h + } + None if demo => Headless::demo(), + None => Headless::default(), + }; + if !imports.is_empty() { + let paths = expand_paths(&imports); + let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; + // what follows acts on the imported photos (already-known files are reported as duplicates) + let mut ids: Vec = r["imported"].as_array().cloned().unwrap_or_default(); + ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned())); + if let Some(first) = ids.first().cloned() { + h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?; + } + } + Box::new(h) + } + }; let mut out = std::io::stdout().lock(); let mut failed = 0; for s in &steps { @@ -474,6 +474,12 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value")) } +/// The port an `--connect` address names, for the recovery hint. The whole address when it has +/// no port, so a hint is never built from a guess. +fn connect_port(addr: &str) -> &str { + addr.rsplit_once(':').map_or(addr, |(_, port)| port) +} + fn render(args: &[String]) -> Result<(), String> { let mut input = None; let mut output = None; diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 9d41873a4..37799f4aa 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -324,3 +324,13 @@ fn a_library_open_in_another_process_is_refused() { assert_eq!(lines[0]["ok"], true); let _ = std::fs::remove_dir_all(&lib); } + +/// Issue #167: when `--connect ADDR` cannot reach the app, the recovery hint must name the port +/// ADDR actually names — following the old fixed "7980" left the retry connecting elsewhere. +#[test] +fn connect_failure_hint_names_the_attempted_port() { + let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None); + assert!(!ok); + assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}"); + assert!(!err.contains("--control 7980"), "{err}"); +} From db640ed1d39c0b53e0f79a45c11d10cf6a1e2f77 Mon Sep 17 00:00:00 2001 From: sakamoto824 Date: Wed, 7 Oct 2026 17:32:12 +0800 Subject: [PATCH 20/30] M16: add Traditional Chinese UI, menus and stock preset localization Co-authored-by: Codex --- README.md | 8 +- ROADMAP.md | 12 +- apps/lightcraft/src/native_menu.rs | 19 +- crates/ui-egui/build.rs | 16 +- crates/ui-egui/locales/ja-formats.json | 23 +- crates/ui-egui/locales/zh-tw-formats.json | 119 ++ crates/ui-egui/locales/zh-tw.json | 1247 +++++++++++++++++++++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/i18n.rs | 266 ++++- crates/ui-egui/src/menubar.rs | 43 +- crates/ui-egui/src/menus.rs | 14 +- crates/ui-egui/src/panels/dialogs.rs | 10 +- crates/ui-egui/src/panels/edit.rs | 21 +- crates/ui-egui/src/panels/grid.rs | 14 +- crates/ui-egui/src/panels/left.rs | 8 +- crates/ui-egui/src/panels/presets.rs | 25 +- crates/ui-egui/src/panels/profiles.rs | 12 +- crates/ui-egui/src/panels/right.rs | 26 +- crates/ui-egui/src/panels/settings.rs | 2 +- docs/localization-zh-tw.md | 46 + docs/parity.md | 2 +- 21 files changed, 1845 insertions(+), 89 deletions(-) create mode 100644 crates/ui-egui/locales/zh-tw-formats.json create mode 100644 crates/ui-egui/locales/zh-tw.json create mode 100644 docs/localization-zh-tw.md diff --git a/README.md b/README.md index e7454f249..76cf14bc1 100644 --- a/README.md +++ b/README.md @@ -334,16 +334,20 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light cargo xtask ci # fmt, clippy, tests, layering, wasm checks ``` -**Japanese text** needs the shared font repo, an optional build input (official releases always include it): +**Japanese and Traditional Chinese interface text** needs the shared font repo, an optional build input (official releases always include it): ```sh git clone https://github.com/storytold/craft-fonts ../craft-fonts CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft ``` -Without it LightCraft builds and runs the same, but Japanese text has no glyphs. Fonts are never committed to this +Without it LightCraft builds and runs the same, but CJK interface text has no glyphs. Fonts are never committed to this repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). +**Interface language:** choose **Edit → Language → 繁體中文(台灣)**, or **Settings → General → Language**. +The choice applies immediately and persists between launches, including native macOS menus and the Keyboard Shortcuts sheet. +See [繁體中文介面說明](docs/localization-zh-tw.md) for build, coverage and control-channel details. + The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI (`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md). diff --git a/ROADMAP.md b/ROADMAP.md index c1cc9d8a7..f8adba02e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | | **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | | **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export | -| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load | +| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English/Japanese/Traditional Chinese UI (see docs/localization-ja.md and docs/localization-zh-tw.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load | ### By kind of user @@ -87,7 +87,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | | M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) | -| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Japanese/English localisation 🟡; accessibility and other locales ⬜) | +| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Japanese/Traditional Chinese localisation 🟡; accessibility and other locales ⬜) | ## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02) @@ -202,3 +202,11 @@ optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. characters stay upright in top-to-bottom columns, with newlines starting columns to the left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical OpenType shaping. The same coverage renderer serves 8/16/32-bit exports. + +## Traditional Chinese (Taiwan) interface + +The persisted `zh-tw` language is available in Edit → Language and Settings → General → Language. +Static and compile-checked dynamic catalogs cover the existing Japanese UI surface, registered command labels, +native/in-window menus and the keyboard reference. Native macOS menus rebuild when changing locale. +Technical errors and release notes retain English fallback; full web glyph coverage remains unverified. +See [docs/localization-zh-tw.md](docs/localization-zh-tw.md). diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 8af250a5b..2ddd62ad4 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec)]) -> String { out.push(';'); } } - let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese()); + let mut s = format!("{:?};", lightcraft_ui_egui::i18n::language()); for (t, items) in bar { s.push_str(t); walk(items, &mut s); @@ -419,6 +419,23 @@ impl NativeMenu { mod tests { use super::*; + #[test] + fn switching_from_english_to_chinese_rebuilds_native_menu_structure() { + use lightcraft_ui_egui::i18n::{Language, set_language}; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + set_language(Language::En); + let bar = menu_bar(&app); + let english = structure_of(&bar); + set_language(Language::ZhTw); + // Titles in the model remain English; the locale must force the native titles + // and the macOS app menu to be rebuilt even when no command ids changed. + assert_ne!(english, structure_of(&bar)); + assert_eq!(label_text("File"), "檔案"); + assert_eq!(label_text("Settings…"), "設定…"); + assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft"); + set_language(Language::En); + } + #[test] fn shortcuts_map_to_accelerators() { assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ))); diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 75c3e03c9..86b8f01fb 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,18 +1,24 @@ -//! Compile translated format strings so Rust checks both languages' placeholders. +//! Compile translated format strings so Rust checks every language's placeholders. use std::{collections::BTreeMap, env, fs, path::PathBuf}; fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=locales/ja-formats.json"); - let messages: BTreeMap = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?; + println!("cargo:rerun-if-changed=locales/zh-tw-formats.json"); + let japanese: BTreeMap = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?; + let chinese: BTreeMap = serde_json::from_str(&fs::read_to_string("locales/zh-tw-formats.json")?)?; + if !japanese.keys().eq(chinese.keys()) { + return Err("Format catalogs must contain the same English keys".into()); + } let mut source = String::from("macro_rules! tr_format {\n"); - for (english, japanese) in messages { + for (english, japanese) in japanese { let en = serde_json::to_string(&english)?; let ja = serde_json::to_string(&japanese)?; + let zh = serde_json::to_string(chinese.get(&english).ok_or("Missing Chinese format")?)?; source.push_str(&format!( - "({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n" + "({en} $(, $($args:tt)*)?) => {{ match $crate::i18n::language() {{ $crate::i18n::Language::Ja => format!({ja} $(, $($args)*)?), $crate::i18n::Language::ZhTw => format!({zh} $(, $($args)*)?), $crate::i18n::Language::En => format!({en} $(, $($args)*)?) }} }};\n" )); } source.push_str("}\npub(crate) use tr_format;\n"); - fs::write(PathBuf::from(env::var("OUT_DIR")?).join("ja-formats.rs"), source)?; + fs::write(PathBuf::from(env::var("OUT_DIR")?).join("formats.rs"), source)?; Ok(()) } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 9da942f87..9e3515464 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -10,9 +10,14 @@ "Add “{}” to Favorites": "「{}」をお気に入りに追加", "Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}", "Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}", + "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚", "All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします", + "Auto ({})": "自動 ({})", "Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用", "Automatic ({} MB)": "自動({}MB)", + "Build 1:1 Previews ({scope})": "1:1プレビューを作成({scope})", + "Build Smart Previews ({scope})": "スマートプレビューを作成({scope})", + "Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})", "Click {}:1": "クリック時 {}:1", "Create {n} Virtual Copies": "{n}枚の仮想コピーを作成", "Decoder: {reason}": "デコーダー:{reason}", @@ -20,6 +25,7 @@ "Delete \"{label}\" (and the mask)": "「{label}」とマスクを削除", "Delete {n} Photos": "{n}枚の写真を削除", "Delete “{current}”": "「{current}」を削除", + "Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})", "Duplicate \"{label}\"": "「{label}」を複製", "Example: {example}": "例:{example}", "Exported {n} photo{}": "{n}枚を書き出しました{:.0}", @@ -29,10 +35,12 @@ "Exporting {} of {total}": "書き出し中:{total}枚中{}枚", "Filter bar — {active} active filter{}": "フィルターバー:{active}件適用中{:.0}", "Healed {n} dust spot{}": "{n}か所のほこりを修復しました{:.0}", + "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}", "Imported {n} point curve preset{}": "{n}件のポイントカーブを読み込みました{:.0}", "Imported {n} preset{}": "{n}件のプリセットを読み込みました{:.0}", "Imported {n} preset{}, {f} file{} not readable": "{n}件のプリセットを読み込みました{:.0}。{f}件は読み込めません{:.0}", "Imported {} photo{}": "{}枚を読み込みました{:.0}", + "Label Set: {name}": "ラベルセット:{name}", "Matches: {}": "一致条件:{}", "Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%", "Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?", @@ -45,19 +53,23 @@ "Proof Copy ({})": "校正用コピー({})", "Proof Preview · {}": "校正プレビュー・{}", "Quick Develop · {n} photos": "クイック現像・{n}枚", + "Quick Develop: {name}": "クイック現像: {name}", "Rated {}": "評価:{}", "Reading photos… {done} of {total}": "写真を読み込み中:{total}枚中{done}枚", + "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚", "Redo {}": "やり直す:{}", "Remove “{}” from Favorites": "「{}」をお気に入りから削除", "Rename {n} Photos…": "{n}枚の写真の名前を変更…", "Rename “{name}”": "「{name}」の名前を変更", "Rendering on the CPU: {why}": "CPUで描画:{why}", + "Reset {name}": "{name}をリセット", "Reset {} Channel": "{}チャンネルをリセット", "Saved export preset “{}”": "書き出しプリセット「{}」を保存しました", "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}", "Showing {n} of {}": "{}枚中{n}枚を表示", "Smart album: {}": "スマートアルバム:{}", "Undo {}": "取り消す:{}", + "Updated “{name}”": "「{name}」を更新しました", "Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{} — Rust製の写真管理・RAW現像アプリ", "Version: {}": "バージョン:{}", "none (CPU rendering): {why}": "なし(CPU描画):{why}", @@ -72,6 +84,8 @@ "{matching} of {t} photos": "{t}枚中{matching}枚", "{matching} photos": "{matching}枚", "{mode} spot {} of {}": "{mode}:{1}か所中{0}か所", + "{month}": "{month}", + "{month} {year}": "{month} {year}", "{msg} ({failed} failed)": "{msg}({failed}件失敗)", "{name} Label": "{name}のラベル", "{name} Off": "{name}を無効にする", @@ -86,8 +100,12 @@ "{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新", "{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{n}枚を{verb}{:.0}", + "{weekday}, {day}": "{weekday}, {day}", + "{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}", "{what} merge added": "{what}合成を追加しました", "{what} merge failed: {e}": "{what}合成に失敗:{e}", + "{year}": "{year}", "{} and higher": "{}以上", "{} files": "{}個のファイル", "{} files and {f} folder{}": "{}個のファイルと{f}個のフォルダ{:.0}", @@ -97,8 +115,5 @@ "{} spot(s) on this photo": "この写真に{}か所のスポット", "· {dups} already in the library": "・{dups}枚は読み込み済み", "· {n} photo{}": "・{n}枚{:.0}", - "… and {more} more": "…ほか{more}件", - "Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}", - "Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚", - "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚" + "… and {more} more": "…ほか{more}件" } diff --git a/crates/ui-egui/locales/zh-tw-formats.json b/crates/ui-egui/locales/zh-tw-formats.json new file mode 100644 index 000000000..54a0b5193 --- /dev/null +++ b/crates/ui-egui/locales/zh-tw-formats.json @@ -0,0 +1,119 @@ +{ + " · {skipped} skipped (file exists)": " · 已略過 {skipped} 個(檔案已存在)", + " · {} duplicate{} skipped": " · 已略過 {} 張重複照片{:.0}", + " · {} failed: {}": " · {} 個失敗:{}", + " · {} moved": " · 已移動 {} 個", + " · {} not readable": " · {} 個無法讀取", + ", {failed} file{} not readable": ",{failed} 個檔案無法讀取{:.0}", + "Add {local_n} to My Photos": "將 {local_n} 張加入我的照片", + "Add {} Photo{}": "新增 {} 張照片{:.0}", + "Add “{}” to Favorites": "將「{}」加入喜好項目", + "Added {n} photo{} to My Photos": "已將 {n} 張照片加入我的照片{:.0}", + "Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}", + "Adding photos… {} of {}": "正在新增照片… {}/{}", + "All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。", + "Auto ({})": "自動({})", + "Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片", + "Automatic ({} MB)": "自動({} MB)", + "Build 1:1 Previews ({scope})": "建立 1:1 預覽({scope})", + "Build Smart Previews ({scope})": "建立智慧型預覽({scope})", + "Build Standard-Sized Previews ({scope})": "建立標準尺寸預覽({scope})", + "Click {}:1": "點擊時 {}:1", + "Create {n} Virtual Copies": "建立 {n} 張虛擬副本", + "Decoder: {reason}": "解碼器:{reason}", + "Delete \"{label}\"": "刪除「{label}」", + "Delete \"{label}\" (and the mask)": "刪除「{label}」及遮色片", + "Delete {n} Photos": "刪除 {n} 張照片", + "Delete “{current}”": "刪除「{current}」", + "Discard Smart Previews ({scope})": "捨棄智慧型預覽({scope})", + "Duplicate \"{label}\"": "複製「{label}」", + "Example: {example}": "範例:{example}", + "Exported {n} photo{}": "已匯出 {n} 張照片{:.0}", + "Exported {ok} of {total} photo{}": "已匯出 {ok}/{total} 張照片{:.0}", + "Exported {} point curve preset{}": "已匯出 {} 個點曲線預設集{:.0}", + "Exported {} preset{}": "已匯出 {} 個預設集{:.0}", + "Exporting {} of {total}": "正在匯出第 {}/{total} 張", + "Filter bar — {active} active filter{}": "篩選列 — 已套用 {active} 個篩選條件{:.0}", + "Healed {n} dust spot{}": "已修復 {n} 個灰塵斑點{:.0}", + "Import cancelled · {} photo{} added": "已取消匯入 · 已加入 {} 張照片{:.0}", + "Imported {n} point curve preset{}": "已匯入 {n} 個點曲線預設集{:.0}", + "Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}", + "Imported {} photo{}": "已匯入 {} 張照片{:.0}", + "Label Set: {name}": "標籤集:{name}", + "Matches: {}": "符合條件:{}", + "Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?", + "Named ({named_n})": "已命名({named_n})", + "Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定", + "Preset: {name}": "預設集:{name}", + "Preview only — editing the camera's embedded JPEG ({})": "僅供預覽 — 正在編輯相機內嵌的 JPEG({})", + "Preview only — {}": "僅供預覽 — {}", + "Profile: {}": "描述檔:{}", + "Proof Copy ({})": "校樣副本({})", + "Proof Preview · {}": "校樣預覽 · {}", + "Quick Develop · {n} photos": "快速顯像 · {n} 張照片", + "Quick Develop: {name}": "快速顯像:{name}", + "Rated {}": "評分:{}", + "Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}", + "Reading photos… {} of {}": "正在讀取照片… {}/{}", + "Redo {}": "重做 {}", + "Remove “{}” from Favorites": "將「{}」從喜好項目移除", + "Rename {n} Photos…": "重新命名 {n} 張照片…", + "Rename “{name}”": "重新命名「{name}」", + "Rendering on the CPU: {why}": "使用 CPU 算繪:{why}", + "Reset {name}": "重設{name}", + "Reset {} Channel": "重設 {} 通道", + "Saved export preset “{}”": "已儲存匯出預設集「{}」", + "Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}", + "Showing {n} of {}": "顯示 {n}/{} 張", + "Smart album: {}": "智慧型相簿:{}", + "Undo {}": "復原 {}", + "Updated “{name}”": "已更新「{name}」", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {} — 以純 Rust 獨立實作的照片管理與 RAW 顯像程式。", + "Version: {}": "版本:{}", + "none (CPU rendering): {why}": "無(使用 CPU 算繪):{why}", + "the {n} selected photos": "選取的 {n} 張照片", + "the {} photos in view": "目前顯示的 {} 張照片", + "these {count} photos": "這 {count} 張照片", + "{count} smart preview{} · {:.1} MB": "{count} 張智慧型預覽{:.0} · {:.1} MB", + "{f} folders (and their subfolders)": "{f} 個資料夾(及其子資料夾)", + "{label} Grading": "{label}色彩分級", + "{label} {d:+} on every selected photo": "每張選取照片的{label}調整 {d:+}", + "{label}, {n} photos": "{label},{n} 張照片", + "{matching} of {t} photos": "{matching}/{t} 張照片", + "{matching} photos": "{matching} 張照片", + "{mode} spot {} of {}": "{mode}斑點:第 {}/{} 個", + "{month}": "{month}月", + "{month} {year}": "{year}年{month}月", + "{msg} ({failed} failed)": "{msg}({failed} 個失敗)", + "{name} Label": "{name}標籤", + "{name} Off": "關閉{name}", + "{name} On": "開啟{name}", + "{name} — double-click to reset it": "{name} — 按兩下可重設", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 項變更{:.0}已保留在記憶體,但尚未寫入磁碟:{e}\nLightCraft 會自動重試;現在結束程式會遺失{}。", + "{n} found · {sel} selected": "找到 {n} 張 · 已選取 {sel} 張", + "{n} photos": "{n} 張照片", + "{n} photos in {} are hidden by the filters above": "{}中的 {n} 張照片被上方篩選條件隱藏", + "{n} photo{}": "{n} 張照片{:.0}", + "{n} photo{} match · updates automatically as photos change": "{n} 張照片符合{:.0} · 隨照片變更自動更新", + "{n} photo{} now · updates automatically as photos change": "目前 {n} 張照片{:.0} · 隨照片變更自動更新", + "{sel_n} selected · {counted}": "已選取 {sel_n} 張 · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} {n} 張照片{:.0}", + "{weekday}, {day}": "{day}日 {weekday}", + "{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}", + "{what} merge added": "已新增{what}合併照片", + "{what} merge failed: {e}": "{what}合併失敗:{e}", + "{year}": "{year}年", + "{} and higher": "{} 以上", + "{} files": "{} 個檔案", + "{} files and {f} folder{}": "{} 個檔案與 {f} 個資料夾{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已繪製 {}/4 條參考線 — 沿應為垂直或水平的線條拖曳。", + "{} photos": "{} 張照片", + "{} photo{} not readable": "{} 張照片無法讀取{:.0}", + "{} spot(s) on this photo": "此照片有 {} 個斑點", + "· {dups} already in the library": "· {dups} 張已在照片庫中", + "· {n} photo{}": "· {n} 張照片{:.0}", + "… and {more} more": "…以及另外 {more} 個" +} diff --git a/crates/ui-egui/locales/zh-tw.json b/crates/ui-egui/locales/zh-tw.json new file mode 100644 index 000000000..c56b40fb6 --- /dev/null +++ b/crates/ui-egui/locales/zh-tw.json @@ -0,0 +1,1247 @@ +{ + "+ Group": "+ 群組", + "+ Rule": "+ 規則", + "0: nothing is rejected.": "0:不排除任何照片。", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "篩選條件隱藏了此檢視的照片:請變更條件,或選擇「檢視 → 清除篩選條件」", + "About LightCraft": "關於 LightCraft", + "Activity": "處理狀態", + "Actual size": "實際大小", + "Add": "新增", + "Add Catchlight": "新增眼神光", + "Add Folder to Local": "將資料夾加入本機", + "Add Point Color Sample": "新增點色彩樣本", + "Add Red Eye Correction": "新增紅眼校正", + "Add Remove Spot": "新增移除斑點", + "Add Selected Photos": "新增選取的照片", + "Add a rule (⌥-click: a group)": "新增規則(⌥ 點擊:新增群組)", + "Add in place": "原地加入", + "Add in place: the library references the files where they are; nothing is copied or moved.": "原地加入:照片庫參照檔案目前的位置,不會複製或移動檔案。", + "Add keyword": "新增關鍵字", + "Add number": "加上編號", + "Add to": "加入至", + "Add to Album": "加入相簿", + "Add to Favorites": "加入喜好項目", + "Add to My Photos": "加入我的照片", + "Add to Selected Photos": "加入選取的照片", + "Add to Target Album": "加入目標相簿", + "Add/Subtract/Intersect Mask": "新增/減去/交集遮色片", + "Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。", + "Adding": "正在新增", + "Advanced search…": "進階搜尋…", + "After": "編輯後", + "After 30 days": "30 天後", + "After 7 days": "7 天後", + "After 90 days": "90 天後", + "After a year": "一年後", + "Album": "相簿", + "Albums": "相簿", + "All": "全部", + "All Metadata": "所有中繼資料", + "All Metadata…": "所有中繼資料…", + "All Photos": "所有照片", + "All rights reserved": "保留所有權利", + "All types": "所有類型", + "Alt Text": "替代文字", + "Always": "一律", + "Amount": "強度", + "Aperture": "光圈", + "Application": "應用程式", + "Apply Color Label Set": "套用色彩標籤集", + "Apply Filter Preset": "套用篩選預設集", + "Apply Metadata Preset": "套用中繼資料預設集", + "Apply Point Curve Preset": "套用點曲線預設集", + "Apply Preset": "套用預設集", + "Apply Settings JSON": "套用 JSON 設定", + "Approved": "已核准", + "Aqua": "青色", + "Aqua Hue": "青色色相", + "Aqua Luminance": "青色明度", + "Aqua Saturation": "青色飽和度", + "ArtCraft Website": "ArtCraft 網站", + "ArtCraft — more creative apps": "ArtCraft — 更多創作應用程式", + "As Shot": "拍攝時設定", + "As shot": "拍攝時設定", + "Ascending": "遞增", + "Aspect": "長寬比", + "Aspect Ratio": "長寬比", + "Assisted Culling": "輔助挑片", + "Assisted Culling…": "輔助挑片…", + "Auto": "自動", + "Auto Advance": "自動前進", + "Auto Advance Off": "關閉自動前進", + "Auto Advance On": "開啟自動前進", + "Auto Advance after rating or flagging": "評分或標記後自動前進", + "Auto Advance: move to the next photo after rating or flagging": "自動前進:評分或標記後移至下一張照片", + "Auto Align": "自動對齊", + "Auto B&W Mix": "自動黑白混色", + "Auto Crop": "自動裁切", + "Auto Import": "自動匯入", + "Auto Import Now": "立即自動匯入", + "Auto Import Settings": "自動匯入設定", + "Auto Mask": "自動遮色片", + "Auto Settings": "自動設定", + "Auto Straighten": "自動拉直", + "Auto Sync": "自動同步", + "Auto-Stack by Capture Time": "依拍攝時間自動堆疊", + "Auto-Stack by Capture Time…": "依拍攝時間自動堆疊…", + "Auto-Tag Photos from Tracklog": "依軌跡記錄自動標記照片位置", + "Auto-Tag from Tracklog": "依軌跡記錄自動加入位置標記", + "Auto-Tag from Tracklog…": "依軌跡記錄自動加入位置標記…", + "Automatic": "自動", + "Automatically Write Changes into XMP": "自動將變更寫入 XMP", + "Automatically write changes into XMP sidecars": "自動將變更寫入 XMP 附屬檔案", + "Autumn Gold": "秋日金色", + "B&W": "黑白", + "B&W Mix": "黑白混色", + "B&W Mixer": "黑白混色器", + "Back": "返回", + "Back Up Library…": "備份照片庫…", + "Back to Edit": "返回編輯", + "Back to Edit (E)": "返回編輯(E)", + "Back to Grid": "返回格狀檢視", + "Background": "背景", + "Balance": "平衡", + "Basic": "基本", + "Before": "編輯前", + "Before/After (Y)": "編輯前/後(Y)", + "Before/After Settings": "編輯前/後設定", + "Before/After Split": "編輯前/後分割檢視", + "Before/After Split Top/Bottom": "編輯前/後上下分割檢視", + "Before/After Top/Bottom": "編輯前/後上下檢視", + "Begin Interaction": "開始互動", + "Bit depth": "位元深度", + "Black & White": "黑白", + "Blacks": "黑色色階", + "Bleach Bypass": "漂白略過", + "Bleached": "漂白效果", + "Blending": "混合", + "Blue": "藍色", + "Blue Hour Boost": "藍調時刻增強", + "Blue Hue": "藍色色相", + "Blue Luminance": "藍色明度", + "Blue Primary": "藍色原色", + "Blue Saturation": "藍色飽和度", + "Blue channel": "藍色通道", + "Blue/Yellow Fringe": "藍/黃色邊緣", + "Blurry photos below the score are flagged as rejects.": "焦點分數低於門檻的模糊照片會標記為排除。", + "Boundary Warp": "邊界變形", + "Bright & Airy": "明亮輕盈", + "Bring All to Front": "將所有視窗移至最前方", + "Browse Folder": "瀏覽資料夾", + "Browse Folder…": "瀏覽資料夾…", + "Browse Profiles": "瀏覽描述檔", + "Browse…": "瀏覽…", + "Brush": "筆刷", + "Brush Settings": "筆刷設定", + "Brush Stroke": "筆刷筆觸", + "Build Previews": "建立預覽", + "Build Smart Previews": "建立智慧型預覽", + "By Date": "依日期", + "By day (YYYY/YYYY-MM-DD)": "依日期(YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "依月份(YYYY/YYYY-MM)", + "Calibration": "校正", + "Camera": "相機", + "Cameras and Cards": "相機與記憶卡", + "Cancel": "取消", + "Cancel Interaction": "取消互動", + "Cancel Preview Build": "取消建立預覽", + "Candidate": "候選照片", + "Caption": "圖說", + "Capture Date": "拍攝日期", + "Captured": "拍攝時間", + "Catalog Statistics": "目錄統計", + "Check All": "全部勾選", + "Check Output Path": "檢驗輸出路徑", + "Choose Another Library…": "選擇其他照片庫…", + "Choose Edit Settings to Copy": "選擇要複製的編輯設定", + "Choose Edit Settings to Copy…": "選擇要複製的編輯設定…", + "Choose Folder…": "選擇資料夾…", + "Choose a photo in the grid or filmstrip": "請在格狀檢視或底片顯示窗格中選擇照片", + "Choose…": "選擇…", + "Cinematic": "電影風格", + "City": "城市", + "Clarity": "清晰度", + "Clean Studio": "純淨棚拍", + "Clear": "清除", + "Clear Cache": "清除快取", + "Clear Filters": "清除篩選條件", + "Clear Guides": "清除參考線", + "Clear History": "清除歷史紀錄", + "Clear Preview Cache": "清除預覽快取", + "Clear Quick Collection": "清除快速集合", + "Clear Reference": "清除參考照片", + "Clear all": "全部清除", + "Clear form": "清除表單", + "Cleared History": "已清除歷史紀錄", + "Click Zoom Ratio": "點擊縮放比例", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "點擊標籤即可插入。範例使用 2026 年 1 月 14 日 05:58:48 拍攝的 IMG_0042.CR3。", + "Click photos in the grid to toggle the keyword; Esc stops": "點擊格狀檢視中的照片以加入或移除關鍵字;按 Esc 結束", + "Click the photo to pick a colour · ⇧-click adds more": "點擊照片以選取色彩;⇧ 點擊可新增更多色彩", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "點擊照片以選取色彩;⇧ 點擊可新增更多色彩(最多 5 個)", + "Clone": "仿製", + "Close": "關閉", + "Close Panel": "關閉面板", + "Close Window": "關閉視窗", + "Close all": "全部關閉", + "Close file": "關閉檔案", + "Cloudy": "陰天", + "Collapse All Stacks": "收合所有堆疊", + "Color": "色彩", + "Color Grading": "色彩分級", + "Color Label Names": "色彩標籤名稱", + "Color Label Sets": "色彩標籤集", + "Color Mixer": "混色器", + "Color Noise Reduction": "色彩雜訊抑制", + "Color Priority": "色彩優先", + "Color Range": "色彩範圍", + "Color space": "色彩空間", + "Color space: sRGB (AVIF)": "色彩空間:sRGB(AVIF)", + "Combine files…": "合併檔案…", + "Command Journal": "指令記錄", + "Comments panel": "註解面板", + "Compare": "比較", + "Compare (Shift+C)": "比較(Shift+C)", + "Compare Before and After": "比較編輯前與編輯後", + "Compare needs at least two photos": "比較需要至少兩張照片", + "Component options": "元件選項", + "Compression": "壓縮", + "Concrete Cool": "冷調混凝土", + "Confirm before deleting": "刪除前確認", + "Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認", + "Constrain Crop": "限制裁切範圍", + "Continue Without Saving": "繼續使用但不儲存", + "Contrast": "對比", + "Convert to DNG": "轉換為 DNG", + "Cool Fade": "冷調褪色", + "Cool Morning": "清冷晨光", + "Copy": "複製", + "Copy After's Settings to Before": "將編輯後設定複製至編輯前", + "Copy Before's Settings to After": "將編輯前設定複製至編輯後", + "Copy Edit Settings": "複製編輯設定", + "Copy History Step Settings to Before": "將歷史步驟設定複製至編輯前", + "Copy Metadata": "複製中繼資料", + "Copy as DNG": "複製為 DNG", + "Copy into the library (else use the files where they are)": "複製至照片庫(否則使用原位置的檔案)", + "Copy pages": "複製頁面", + "Copy the files (into the library's Originals/, or a folder you choose)": "複製檔案(至照片庫的 Originals/ 或指定資料夾)", + "Copy to": "複製至", + "Copy to Clipboard": "複製到剪貼簿", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "複製:檔案會複製至下方資料夾,照片庫使用複本,原始檔案維持原狀。", + "Copyright": "著作權", + "Copyright Info URL": "著作權資訊網址", + "Copyright Status": "著作權狀態", + "Country": "國家", + "Create Album": "建立相簿", + "Create Album…": "建立相簿…", + "Create Folder": "建立資料夾", + "Create Folder…": "建立資料夾…", + "Create New Mask": "建立新遮色片", + "Create PDF from clipboard": "從剪貼簿建立 PDF", + "Create PDF from file…": "從檔案建立 PDF…", + "Create PDF from images…": "從圖片建立 PDF…", + "Create Preset": "建立預設集", + "Create Preset…": "建立預設集…", + "Create Proof Copy": "建立校樣副本", + "Create Smart Album": "建立智慧型相簿", + "Create Smart Album from Filter…": "依篩選條件建立智慧型相簿…", + "Create Smart Album…": "建立智慧型相簿…", + "Create Stack": "建立堆疊", + "Create Version": "建立版本", + "Create Version from Step": "依此步驟建立版本", + "Create Virtual Copy": "建立虛擬副本", + "Creator": "創作者", + "Crisp Landscape": "清晰風景", + "Crisp Vista": "清晰遠景", + "Crop": "裁切", + "Crop & Rotate": "裁切與旋轉", + "Crop & Rotate (C)": "裁切與旋轉(C)", + "Crop Aspect": "裁切長寬比", + "Cross Process": "交叉沖洗", + "Culling": "挑片", + "Curve": "曲線", + "Custom": "自訂", + "Custom…": "自訂…", + "Cut": "剪下", + "Cut pages": "剪下頁面", + "Cycle Crop Overlay": "切換裁切疊加", + "Cycle Crop Overlay Orientation": "切換裁切疊加方向", + "Cycle Info Overlay": "切換資訊疊加", + "Cycle Mask Overlay Color": "切換遮色片疊加色彩", + "Cycle Mask Overlay Mode": "切換遮色片疊加模式", + "Cylindrical": "圓柱形", + "Dark & Moody": "暗調氛圍", + "Darken": "變暗", + "Darks": "暗部", + "Date Groups": "日期群組", + "Day": "日", + "Daylight": "日光", + "Decrease Brush Feather": "減少筆刷羽化", + "Decrease Brush Size": "縮小筆刷尺寸", + "Deep Sky": "深邃天空", + "Defringe": "去除邊緣色差", + "Deghost Amount": "去除鬼影強度", + "Dehaze": "去朦朧", + "Delete": "刪除", + "Delete (⌫)": "刪除(⌫)", + "Delete Album": "刪除相簿", + "Delete All Masks": "刪除所有遮色片", + "Delete Color Label Set": "刪除色彩標籤集", + "Delete Export Preset": "刪除匯出預設集", + "Delete Filter Preset": "刪除篩選預設集", + "Delete Imported Profile": "刪除匯入的描述檔", + "Delete Keyword": "刪除關鍵字", + "Delete Keyword Set": "刪除關鍵字集", + "Delete Mask": "刪除遮色片", + "Delete Metadata Preset": "刪除中繼資料預設集", + "Delete Permanently": "永久刪除", + "Delete Photo": "刪除照片", + "Delete Photos": "刪除照片", + "Delete Point Color Sample": "刪除點色彩樣本", + "Delete Point Curve Preset": "刪除點曲線預設集", + "Delete Preset": "刪除預設集", + "Delete Red Eye Correction": "刪除紅眼校正", + "Delete Spot": "刪除斑點", + "Delete Version": "刪除版本", + "Delete all": "全部刪除", + "Delete all spots": "刪除所有斑點", + "Delete mask": "刪除遮色片", + "Delete them": "刪除這些照片", + "Density": "濃度", + "Depth Range": "深度範圍", + "Descending": "遞減", + "Deselect": "取消選取", + "Deselect All": "取消全選", + "Desert Heat": "沙漠熱浪", + "Desert Warmth": "沙漠暖色", + "Detail": "詳細檢視", + "Detail (D)": "詳細檢視(D)", + "Diagonal": "對角線", + "Discard Preview Cache": "捨棄預覽快取", + "Display P3": "Display P3", + "Display theme": "顯示主題", + "Distortion": "變形", + "Document properties…": "文件內容…", + "Don't enlarge": "不放大", + "Done": "完成", + "Drag along the horizon; double-click for Auto": "沿水平線拖曳;按兩下可自動校正", + "Drag over an eye on the photo; the pupil inside is found automatically.": "在照片的眼睛上拖曳,會自動尋找範圍內的瞳孔。", + "Draw Guides": "繪製參考線", + "Duplicate": "複製", + "Duplicate Mask": "複製遮色片", + "Duplicate and Invert Mask": "複製並反轉遮色片", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每個 / 代表一層資料夾;標籤會依照片填入(缺少中繼資料的層級使用「unknown」)。", + "Edit": "編輯", + "Edit (E)": "編輯(E)", + "Edit Capture Time": "編輯拍攝時間", + "Edit Capture Time…": "編輯拍攝時間…", + "Edit Color Label Names": "編輯色彩標籤名稱", + "Edit Color Label Names…": "編輯色彩標籤名稱…", + "Edit Copy for External Editor": "編輯外部編輯器副本", + "Edit Info": "編輯資訊", + "Edit Label Names…": "編輯標籤名稱…", + "Edit Mask Component": "編輯遮色片元件", + "Edit Sections": "編輯區段", + "Edit Smart Album": "編輯智慧型相簿", + "Edit Smart Album…": "編輯智慧型相簿…", + "Edit Spot": "編輯斑點", + "Edit in External Editor": "在外部編輯器中編輯", + "Edited": "已編輯", + "Effect": "效果", + "Effects": "效果", + "Enable Lens Corrections": "啟用鏡頭校正", + "End Interaction": "結束互動", + "English": "English", + "Enter Full Screen": "進入全螢幕", + "Erase": "擦除", + "Every EXIF, GPS and XMP field in the file": "檔案中的所有 EXIF、GPS 與 XMP 欄位", + "Existing previews": "現有預覽", + "Exit": "結束", + "Expand All Stacks": "展開所有堆疊", + "Expand/Collapse Stack": "展開/收合堆疊", + "Export": "匯出", + "Export Album with Preset": "使用預設集匯出相簿", + "Export Album…": "匯出相簿…", + "Export As…": "匯出為…", + "Export Group…": "匯出群組…", + "Export Now": "立即匯出", + "Export Point Curve Presets": "匯出點曲線預設集", + "Export Point Curve Presets…": "匯出點曲線預設集…", + "Export Presets": "匯出預設集", + "Export Presets…": "匯出預設集…", + "Export User Presets…": "匯出使用者預設集…", + "Export with Preset": "使用預設集匯出", + "Export with Previous": "使用上次設定匯出", + "Exporting": "正在匯出", + "Export…": "匯出…", + "Exposure": "曝光", + "Exposure (shutter · aperture · ISO)": "曝光(快門 · 光圈 · ISO)", + "Extended Description": "延伸說明", + "External Editor": "外部編輯器", + "Faded Matte": "褪色霧面", + "Faded Slide": "褪色正片", + "Faded Urban": "褪色城市", + "Favorite Preset": "將預設集加入喜好項目", + "Favorite Profile": "將描述檔加入喜好項目", + "Favorites": "喜好項目", + "Feather": "羽化", + "File": "檔案", + "File & date": "檔案與日期", + "File Name": "檔案名稱", + "File Path": "檔案路徑", + "File Size": "檔案大小", + "File name": "檔案名稱", + "Fill Edges": "填補邊緣", + "Film": "底片", + "Filmstrip": "底片顯示窗格", + "Filmstrip (/)": "底片列(/)", + "Filter": "篩選", + "Filter Bar": "篩選列", + "Filter Presets": "篩選預設集", + "Filter bar": "篩選列", + "Filter fields": "篩選欄位", + "Find Dust Spots": "尋找灰塵斑點", + "Find Missing Photos": "尋找遺失的照片", + "Find Missing Photos…": "尋找遺失的照片…", + "Find Similar Photos": "尋找相似照片", + "Find tools and commands…": "尋找工具與指令…", + "Find…": "尋找…", + "Fit": "符合視窗", + "Fit (F)": "符合視窗(F)", + "Fit on Screen": "符合螢幕", + "Fit to width": "符合寬度", + "Fit visible": "符合可見範圍", + "Flag": "旗標", + "Flag as Pick": "標記為精選", + "Flag as Reject": "標記為排除", + "Flash": "閃光燈", + "Flip H": "水平翻轉", + "Flip Horizontal": "水平翻轉", + "Flip Overlay (⇧O)": "翻轉疊加(⇧O)", + "Flip V": "垂直翻轉", + "Flip Vertical": "垂直翻轉", + "Flow": "流量", + "Fluorescent": "螢光燈", + "Focal length": "焦距", + "Folder": "資料夾", + "Folder name (renamed on disk; its photos follow)": "資料夾名稱(會在磁碟上重新命名,照片也會隨之移動)", + "Folders": "資料夾", + "Folders and {date} use the capture time; a photo without one uses today's date.": "資料夾與 {date} 使用拍攝時間;沒有拍攝時間的照片使用今天的日期。", + "Food": "美食", + "Forget Unchanged Local Photos": "忘記未變更的本機照片", + "Forget unchanged photos": "忘記未變更的照片", + "Form fields panel": "表單欄位面板", + "Format": "格式", + "Forward": "前進", + "Free": "自由", + "Fresh & Bright": "清新明亮", + "Friday": "星期五", + "Full": "完整", + "Full Screen Preview": "全螢幕預覽", + "Full screen mode": "全螢幕模式", + "Full size": "完整尺寸", + "GPS Track Log": "GPS 軌跡記錄", + "GPU Rendering": "GPU 算繪", + "General": "一般", + "Geometry": "幾何", + "Get Develop Settings": "取得顯像設定", + "Global Hue": "整體色相", + "Global Luminance": "整體明度", + "Global Saturation": "整體飽和度", + "Glossy": "亮面", + "Go to History Step": "前往歷史步驟", + "Golden": "黃金比例", + "Golden Glow": "金色光暈", + "Golden Hour": "黃金時刻", + "Grain": "顆粒", + "Graphic": "圖像", + "Green": "綠色", + "Green Amount": "綠色強度", + "Green Hue": "綠色色相", + "Green Hue High": "綠色色相上限", + "Green Hue Low": "綠色色相下限", + "Green Luminance": "綠色明度", + "Green Primary": "綠色原色", + "Green Saturation": "綠色飽和度", + "Green channel": "綠色通道", + "Grid": "格狀檢視", + "Grid Info": "格狀檢視資訊", + "Gritty Street": "粗礪街頭", + "Group": "群組", + "Group by Date: Automatic": "依日期分組:自動", + "Group by Day": "依日分組", + "Group by Month": "依月分組", + "Group by Year": "依年分組", + "Group by date": "依日期分組", + "Group into Stack": "分組為堆疊", + "Guided": "引導式", + "Guided Upright": "引導式透視校正", + "Guided Upright Guides": "引導式透視校正參考線", + "HDR Merge": "HDR 合併", + "HDR Panorama Merge": "HDR 全景合併", + "HDR Panorama with Last Settings": "使用上次設定合併 HDR 全景", + "HDR Panorama…": "HDR 全景…", + "HDR with Last Settings": "使用上次設定合併 HDR", + "HDR…": "HDR…", + "Heal": "修復", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點", + "Height": "高度", + "Height (px)": "高度(像素)", + "Help": "說明", + "Hide LightCraft": "隱藏 LightCraft", + "Hide Mask": "隱藏遮色片", + "Hide Others": "隱藏其他應用程式", + "Hides this shortcut only; the folder and its photos stay as they are": "只隱藏此捷徑,資料夾及照片維持原狀", + "High": "高", + "High Contrast B&W": "高對比黑白", + "Highlight": "亮部", + "Highlights": "亮部", + "Highlights Hue": "亮部色相", + "Highlights Luminance": "亮部明度", + "Highlights Saturation": "亮部飽和度", + "Highlights split": "亮部分界", + "Histogram": "色階分佈圖", + "History": "歷史紀錄", + "History & Activity (Y)": "歷史紀錄與處理狀態(Y)", + "Horizontal": "水平", + "Horizontal text": "水平文字", + "Hue": "色相", + "Hue Range": "色相範圍", + "If file exists": "檔案已存在時", + "Image": "圖片", + "Import": "匯入", + "Import Date": "匯入日期", + "Import Photos": "匯入照片", + "Import Photos…": "匯入照片…", + "Import Point Curve Presets": "匯入點曲線預設集", + "Import Point Curve Presets…": "匯入點曲線預設集…", + "Import Presets": "匯入預設集", + "Import Presets…": "匯入預設集…", + "Import Profiles": "匯入描述檔", + "Import Profiles & Presets…": "匯入描述檔與預設集…", + "Import from Device": "從裝置匯入", + "Import from Folder…": "從資料夾匯入…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "使用「檔案 → 匯入照片…」(Cmd+Shift+I),或將照片拖放至此", + "Imported Photos": "已匯入的照片", + "Importing": "正在匯入", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "匯入會掃描來源中的照片,不會將資料夾加入本機(若要直接使用資料夾中的檔案,請選擇「本機 → 瀏覽資料夾…」)。", + "In library": "在照片庫中", + "In memory — nothing is saved": "僅存於記憶體,尚未儲存", + "In use": "使用中", + "In-memory session: these settings last until LightCraft quits.": "記憶體工作階段:這些設定只保留至 LightCraft 結束。", + "Include subfolders": "包含子資料夾", + "Increase Brush Feather": "增加筆刷羽化", + "Increase Brush Size": "放大筆刷尺寸", + "Info": "資訊", + "Info (I)": "資訊(I)", + "Info overlay": "資訊疊加", + "Inspect Photo": "檢驗照片", + "Instant ’70s": "七〇年代拍立得", + "Interface": "介面", + "Interface language": "介面語言", + "Intersect": "交集", + "Intersect with": "與下列項目取交集", + "Into one folder": "放入同一資料夾", + "Invert": "反轉", + "Invert Mask": "反轉遮色片", + "JPEG (Large)": "JPEG(大尺寸)", + "JPEG (Small)": "JPEG(小尺寸)", + "Join the ArtCraft Discord": "加入 ArtCraft Discord", + "Join the ArtCraft Discord…": "加入 ArtCraft Discord…", + "Keep in Local": "保留在本機", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "請放在空間充足的磁碟:每張智慧型預覽約 1 MB。縮圖快取仍保留在照片庫中。", + "Keyboard Shortcuts": "鍵盤快速鍵", + "Keyboard shortcuts": "鍵盤快速鍵", + "Keyword": "關鍵字", + "Keyword Painter": "關鍵字筆刷", + "Keyword Set": "關鍵字集", + "Keyword Sets": "關鍵字集", + "Keyword Suggestions": "關鍵字建議", + "Keyword to paint": "要塗上的關鍵字", + "Keywords": "關鍵字", + "Keywords (K)": "關鍵字(K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "新增的關鍵字會顯示於此;⌥1–⌥9 可套用。", + "Landscape": "風景", + "Language": "語言", + "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "詳細檢視預覽的最長邊尺寸;較大的尺寸在大螢幕上更清晰,但速度較慢。", + "Last": "最後", + "Last view": "上次檢視", + "Lateral Chromatic Aberration": "橫向色差", + "Layer": "圖層", + "Layers": "圖層", + "Leave them": "保留這些照片", + "Lens": "鏡頭", + "Lens Corrections": "鏡頭校正", + "Level": "水平校正", + "Library": "照片庫", + "Library Info": "照片庫資訊", + "Library Originals": "照片庫原始檔案", + "Library Preferences": "照片庫偏好設定", + "Library State": "照片庫狀態", + "Light": "光線", + "LightCraft Default": "LightCraft 預設值", + "LightCraft Help": "LightCraft 輔助說明", + "LightCraft Preset": "LightCraft 預設集", + "LightCraft Presets": "LightCraft 預設集", + "LightCraft Website": "LightCraft 網站", + "LightCraft couldn't find where to keep your library.": "LightCraft 找不到儲存照片庫的位置。", + "LightCraft couldn't open the library at": "LightCraft 無法開啟此位置的照片庫:", + "LightCraft on GitHub": "GitHub 上的 LightCraft", + "LightCraft website": "LightCraft 網站", + "LightCraft — Second Window": "LightCraft — 第二視窗", + "Lights": "亮調", + "Limit file size (KB, 0 = off)": "檔案大小上限(KB,0 = 不限制)", + "Linear Gradient": "線性漸層", + "List Albums": "列出相簿", + "List Develop Controls": "列出顯像控制項", + "List History": "列出歷史紀錄", + "List Presets": "列出預設集", + "List Profiles": "列出描述檔", + "List this folder in Local from now on": "將此資料夾永久列在本機", + "Local": "本機", + "Local folders": "本機資料夾", + "Local library — no cloud account needed": "本機照片庫,無需雲端帳號", + "Locate Missing File…": "尋找遺失的檔案…", + "Locate Photo": "尋找照片", + "Location": "位置", + "Long Edge": "長邊", + "Looking for photos…": "正在尋找照片…", + "Lossless": "無損", + "Low": "低", + "Luminance": "明度", + "Luminance Range": "明度範圍", + "Lush Greens": "濃郁綠意", + "Magenta": "洋紅色", + "Magenta Hue": "洋紅色色相", + "Magenta Luminance": "洋紅色明度", + "Magenta Saturation": "洋紅色飽和度", + "Make Candidate the Select": "將候選照片設為選定照片", + "Manual": "手動", + "Marquee zoom": "選取範圍縮放", + "Masking": "遮色片", + "Masking (M)": "遮色片(M)", + "Masks": "遮色片", + "Match": "符合", + "Match Total Exposures": "符合總曝光量", + "Matte": "霧面", + "Matte Soft": "柔和霧面", + "Med": "中", + "Medium Contrast": "中等對比", + "Megapixels": "百萬像素", + "Memory Budget": "記憶體預算", + "Memory Usage": "記憶體使用量", + "Memory for caches": "快取記憶體", + "Menu": "選單", + "Merge": "合併", + "Merge Keywords": "合併關鍵字", + "Merge into…": "合併至…", + "Merging…": "正在合併…", + "Metadata": "中繼資料", + "Metadata Preset": "中繼資料預設集", + "Metadata Presets": "中繼資料預設集", + "Metadata preset": "中繼資料預設集", + "Midpoint": "中點", + "Midtones": "中間調", + "Midtones Hue": "中間調色相", + "Midtones Luminance": "中間調明度", + "Midtones Saturation": "中間調飽和度", + "Midtones split": "中間調分界", + "Minimize": "最小化", + "Missing Photos": "遺失的照片", + "Misty Morning": "薄霧清晨", + "Mode": "模式", + "Modified Date": "修改日期", + "Monday": "星期一", + "Mono Red Filter": "黑白紅色濾鏡", + "Mono Rich": "濃郁黑白", + "Mono Soft": "柔和黑白", + "Monochrome": "單色", + "Month": "月", + "Moody": "氛圍風格", + "Moody Portrait": "氛圍人像", + "More": "更多", + "More preset options": "更多預設集選項", + "Move": "移動", + "Move Album": "移動相簿", + "Move Down in Stack": "在堆疊中下移", + "Move Folder": "移動資料夾", + "Move Folder To…": "將資料夾移至…", + "Move Mask": "移動遮色片", + "Move Preset to Group": "將預設集移至群組", + "Move Up in Stack": "在堆疊中上移", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "移動檔案(至照片庫的 Originals/ 或指定資料夾),並從來源移除", + "Move them": "移動這些照片", + "Move to": "移至", + "Move to Group": "移至群組", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "移動:檔案會移至下方資料夾;每份原始檔案及 XMP 附屬檔案只會在複本通過驗證後才從來源移除。重複及失敗的檔案會留在原處。", + "Muted": "低彩度", + "Muted Film": "低彩度底片", + "My Photos": "我的照片", + "My Photos Panel": "我的照片面板", + "Name": "名稱", + "Named": "已命名", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "名稱會顯示於標籤選單、篩選列與資訊面板,並寫入 XMP。留空則使用色彩名稱。", + "Navigator": "導覽器", + "Needs Work": "需要調整", + "Neon Dusk": "霓虹暮色", + "Neon Night": "霓虹之夜", + "Neutral": "中性", + "Never": "永不", + "New": "新增", + "New Album": "新增相簿", + "New Album…": "新增相簿…", + "New Folder…": "新增資料夾…", + "New Group…": "新增群組…", + "New Smart Album": "新增智慧型相簿", + "New Smart Album from Filter…": "依篩選條件新增智慧型相簿…", + "New Smart Album…": "新增智慧型相簿…", + "New album": "新增相簿", + "New album…": "新增相簿…", + "New blank PDF": "新增空白 PDF", + "New name": "新名稱", + "New…": "新增…", + "Next Photo": "下一張照片", + "Night Blue": "夜色藍調", + "No": "否", + "No Camera or Card Found": "找不到相機或記憶卡", + "No Date Groups": "沒有日期群組", + "No Stars": "無星等", + "No Tool": "無工具", + "No automatic versions.": "沒有自動版本。", + "No camera": "無相機", + "No date": "無日期", + "No dust spots found": "找不到灰塵斑點", + "No edits yet.": "尚未編輯。", + "No exposure information": "沒有曝光資訊", + "No filter presets yet": "尚無篩選預設集", + "No lens data embedded in this file (DNG lens opcodes only).": "此檔案未內嵌鏡頭資料(僅支援 DNG 鏡頭操作碼)。", + "No masks yet. Choose a mask type above.": "尚無遮色片。請在上方選擇遮色片類型。", + "No matching photos": "沒有符合的照片", + "No metadata found": "找不到中繼資料", + "No new presets": "沒有新預設集", + "No photo selected": "尚未選取照片", + "No photos": "沒有照片", + "No photos found": "找不到照片", + "No photos in this folder": "此資料夾沒有照片", + "No photos match the active filters": "沒有照片符合目前的篩選條件", + "No photos match the filters (View → Clear Filters)": "沒有照片符合篩選條件(檢視 → 清除篩選條件)", + "No raw photos yet: cameras appear here once their photos are in the library.": "尚無 RAW 照片:將照片加入照片庫後,相機會顯示於此。", + "No reference photo": "沒有參考照片", + "No usable GPU found: rendering on the CPU": "找不到可用的 GPU:使用 CPU 算繪", + "No versions yet. Create one to keep this look.": "尚無版本。建立版本即可保留目前的效果。", + "Noise": "雜訊", + "Noise Reduction": "雜訊抑制", + "Non-raw photos": "非 RAW 照片", + "None": "無", + "Nothing to compare": "沒有可比較的照片", + "Nothing to survey": "沒有可總覽的照片", + "Nudge Develop Value": "微調顯像數值", + "OK": "確定", + "Object": "物件", + "Off": "關閉", + "Offset X": "X 位移", + "Offset Y": "Y 位移", + "Opacity": "不透明度", + "Opacity (%)": "不透明度(%)", + "Open Library": "開啟照片庫", + "Open Library…": "開啟照片庫…", + "Open in": "開啟於", + "Open in Detail": "在詳細檢視中開啟", + "Open the place in OpenStreetMap": "在 OpenStreetMap 中開啟此位置", + "Open…": "開啟…", + "Optics": "光學", + "Optimize Library": "最佳化照片庫", + "Optional name": "名稱(選填)", + "Options": "選項", + "Orange": "橙色", + "Orange Hue": "橙色色相", + "Orange Luminance": "橙色明度", + "Orange Saturation": "橙色飽和度", + "Orientation": "方向", + "Original": "原始", + "Original + Settings": "原始檔與設定", + "Original files": "原始檔案", + "Original offline · editing the smart preview": "原始檔案離線,正在編輯智慧型預覽", + "Original offline · no smart preview": "原始檔案離線,無智慧型預覽", + "Originals": "原始檔案", + "Other images (JPEG, PNG, TIFF, HEIC…)": "其他影像(JPEG、PNG、TIFF、HEIC…)", + "Overlay": "疊加", + "Overwrite": "覆寫", + "Pages": "頁面", + "Paint": "繪製", + "Paint only areas like the one under the brush": "只塗繪與筆刷下方相似的區域", + "Paint over a distraction on the photo to remove it.": "在照片中不需要的物件上塗繪以將其移除。", + "Paint overlay": "塗繪疊加", + "Panorama Merge": "全景合併", + "Panorama with Last Settings": "使用上次設定合併全景", + "Panorama…": "全景…", + "Parametric curve": "參數曲線", + "Paste": "貼上", + "Paste Edit Settings": "貼上編輯設定", + "Paste Metadata": "貼上中繼資料", + "Paste Selected Settings": "貼上選取的設定", + "Paste Selected Settings…": "貼上選取的設定…", + "Paste Settings from Previous": "貼上前一張照片的設定", + "Paste pages": "貼上頁面", + "Pastel Haze": "粉彩薄霧", + "Percent": "百分比", + "Percentage": "百分比", + "Performance": "效能", + "Perspective": "透視", + "Pet Eye": "寵物眼睛", + "Pet Eye Catchlight": "寵物眼神光", + "Photo": "照片", + "Photo Grid": "照片格狀檢視", + "Photo Grid (G)": "照片格狀檢視(G)", + "Photo Merge": "照片合併", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "「照片 → 在外部編輯器中編輯」(⇧⌘E)會算繪 16 位元 TIFF 副本,與原始照片堆疊後在此開啟(macOS 填應用程式名稱,其他平台填程式路徑)。", + "Photos": "照片", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "曾在本機瀏覽、但未加入或變更的照片,在其資料夾超過此期間未瀏覽時會移出目錄(開啟照片庫時檢驗)。檔案仍保留在磁碟上,再次瀏覽資料夾即可重新顯示。", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "此資料夾中新出現的照片(連線拍攝、掃描器或同步程式)會在檔案完整後立即加入。", + "Pick": "精選", + "Pick a colour on the photo with the eyedropper.": "使用滴管在照片上選取色彩。", + "Pick the sharpest photo of each burst": "選取每組連拍中最清晰的照片", + "Picked": "已標記精選", + "Picks": "精選照片", + "Pixels": "像素", + "Point Color": "點色彩", + "Point Curve": "點曲線", + "Point Curve Presets": "點曲線預設集", + "Point curve": "點曲線", + "Portrait": "人像", + "Position": "位置", + "Preferences": "偏好設定", + "Preferences…": "偏好設定…", + "Preset": "預設集", + "Preset name": "預設集名稱", + "Presets": "預設集", + "Presets & Profiles": "預設集與描述檔", + "Presets (Shift+P)": "預設集(Shift+P)", + "Presets ▾": "預設集", + "Preview": "預覽", + "Preview Build Progress": "預覽建立進度", + "Preview only": "僅供預覽", + "Preview size": "預覽尺寸", + "Previews": "預覽", + "Previous Photo": "上一張照片", + "Print": "列印", + "Print…": "列印…", + "Profile": "描述檔", + "Profile Browser": "描述檔瀏覽器", + "Profile Distortion": "描述檔變形校正", + "Profile Menu": "描述檔選單", + "Profile Vignetting": "描述檔暗角校正", + "Profile:": "描述檔:", + "Profiles": "描述檔", + "Projection": "投影", + "Properties": "屬性", + "Public domain": "公眾領域", + "Pupil Size": "瞳孔尺寸", + "Purple": "紫色", + "Purple Amount": "紫色強度", + "Purple Hue": "紫色色相", + "Purple Hue High": "紫色色相上限", + "Purple Hue Low": "紫色色相下限", + "Purple Luminance": "紫色明度", + "Purple Saturation": "紫色飽和度", + "Put the locations you removed from Local back (no files change)": "恢復從本機移除的位置(不變更任何檔案)", + "Quality": "品質", + "Query Photos": "搜尋照片", + "Quick Collection": "快速集合", + "Quick Develop": "快速顯像", + "Quiet Green": "靜謐綠色", + "Quit": "結束", + "Quit LightCraft": "結束 LightCraft", + "Radial Gradient": "放射狀漸層", + "Radius": "半徑", + "Range": "範圍", + "Rating": "評分", + "Ratings & flags": "評分與旗標", + "Raw default": "RAW 預設值", + "Raw defaults": "RAW 預設值", + "Raw files": "RAW 檔案", + "Raw photos": "RAW 照片", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "將 RAW 照片轉為 DNG,並內嵌編輯設定。尺寸、色彩與輸出選項不適用。", + "Read Metadata from File": "從檔案讀取中繼資料", + "Read mode": "閱讀模式", + "Reading": "正在讀取", + "Reading folder…": "正在讀取資料夾…", + "Recently Added": "最近加入", + "Recently Deleted": "最近刪除", + "Red": "紅色", + "Red Eye": "紅眼", + "Red Hue": "紅色色相", + "Red Luminance": "紅色明度", + "Red Primary": "紅色原色", + "Red Saturation": "紅色飽和度", + "Red channel": "紅色通道", + "Red/Cyan Fringe": "紅/青色邊緣", + "Redo": "重做", + "Reference": "參考", + "Reference View": "參考檢視", + "Reference the files where they are": "參照檔案目前的位置", + "Refine Mask Edges": "調整遮色片邊緣", + "Refresh Source": "重新整理來源", + "Refresh Source (/)": "重新整理來源(/)", + "Reject": "排除", + "Reject below focus": "排除低於此焦點分數的照片", + "Rejected": "已排除", + "Rejects": "排除的照片", + "Reload from Disk": "從磁碟重新載入", + "Remove": "移除", + "Remove (H)": "移除(H)", + "Remove CA": "移除色差", + "Remove Chromatic Aberration": "移除色差", + "Remove from Album": "從相簿移除", + "Remove from Favorites": "從喜好項目移除", + "Remove from Local": "從本機移除", + "Remove from Photo": "從照片移除", + "Remove from Selected Photos": "從選取的照片移除", + "Remove from Stack": "從堆疊移除", + "Remove from survey": "從總覽移除", + "Remove location info": "移除位置資訊", + "Remove this filter": "移除此篩選條件", + "Remove this group": "移除此群組", + "Remove this rule": "移除此規則", + "Rename": "重新命名", + "Rename Album": "重新命名相簿", + "Rename Folder": "重新命名資料夾", + "Rename Folder…": "重新命名資料夾…", + "Rename Keyword": "重新命名關鍵字", + "Rename Keyword…": "重新命名關鍵字…", + "Rename Mask": "重新命名遮色片", + "Rename Photos": "重新命名照片", + "Rename Photos…": "重新命名照片…", + "Rename Preset": "重新命名預設集", + "Rename Preview": "重新命名預覽", + "Rename Template Tags": "重新命名範本標籤", + "Rename Version": "重新命名版本", + "Rename…": "重新命名…", + "Rendering": "算繪", + "Rendering…": "正在算繪…", + "Reset": "重設", + "Reset All Curves": "重設所有曲線", + "Reset Before to Import State": "將編輯前重設為匯入時狀態", + "Reset Control": "重設控制項", + "Reset Crop": "重設裁切", + "Reset Edits": "重設編輯", + "Reset Parametric Curve": "重設參數曲線", + "Reset Section": "重設區段", + "Reset Tone Curve": "重設色調曲線", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重設所有點曲線通道與參數曲線(按兩下通道可只重設該通道)", + "Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)", + "Resize to": "調整尺寸為", + "Resolution (ppi)": "解析度(ppi)", + "Restore": "還原", + "Restore Library from Backup…": "從備份還原照片庫…", + "Restore Version": "還原版本", + "Retouch": "修飾", + "Revert": "回復至上次儲存", + "Review Import": "檢視匯入", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "在照片上按右鍵 → 設為參考照片,再選擇「檢視 → 參考檢視」(Shift+R)", + "Right-click a preset to delete it": "在預設集上按右鍵即可刪除", + "Rights Usage Terms": "使用權利條款", + "Rotate": "旋轉", + "Rotate Crop Aspect": "旋轉裁切長寬比", + "Rotate Left": "向左旋轉", + "Rotate Right": "向右旋轉", + "Roughness": "粗糙度", + "Roundness": "圓度", + "Same as raw default": "與 RAW 預設值相同", + "Sample Mask Color": "取樣遮色片色彩", + "Sample a colour on the photo": "在照片上取樣色彩", + "Saturation": "飽和度", + "Saturation Range": "飽和度範圍", + "Saturday": "星期六", + "Save": "儲存", + "Save As…": "另存新檔…", + "Save Color Label Set": "儲存色彩標籤集", + "Save Current Filter as Preset…": "將目前篩選條件儲存為預設集…", + "Save Current Keywords as Set…": "將目前關鍵字儲存為關鍵字集…", + "Save Export Preset": "儲存匯出預設集", + "Save Filter Preset": "儲存篩選預設集", + "Save Keyword Set": "儲存關鍵字集", + "Save Metadata Preset": "儲存中繼資料預設集", + "Save Metadata Preset…": "儲存中繼資料預設集…", + "Save Metadata to File": "將中繼資料儲存至檔案", + "Save Point Curve Preset": "儲存點曲線預設集", + "Save Point Curve…": "儲存點曲線…", + "Save as Preset": "儲存為預設集", + "Save as Smart Album…": "儲存為智慧型相簿…", + "Save as set": "儲存為關鍵字集", + "Save as…": "另存新檔…", + "Save preset": "儲存預設集", + "Scale": "縮放", + "Scanning": "正在掃描", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "分數會保留在照片上:可依焦點與相似照片中的最佳照片篩選,或建立智慧型相簿。", + "Screen": "螢幕", + "Search Photos": "搜尋照片", + "Search…": "搜尋…", + "Seasons": "四季", + "Second Window": "第二視窗", + "Select": "選定照片", + "Select All": "全選", + "Select Luminance Range": "選取明度範圍", + "Select Mask": "選取遮色片", + "Select Photos": "選取照片", + "Select Spot": "選取斑點", + "Select a photo to edit": "選取要編輯的照片", + "Select by": "選取依據", + "Select by Flag, Rating or Label": "依旗標、評分或標籤選取", + "Select photos, then choose View → Survey (N)": "選取照片後,選擇「檢視 → 總覽」(N)", + "Select two photos, then choose View → Compare (Shift+C)": "選取兩張照片後,選擇「檢視 → 比較」(Shift+C)", + "Selected": "已選取", + "Selenium Tone": "硒調色", + "Send Feedback…": "傳送意見回饋…", + "Sepia Tone": "深褐色調", + "Services": "服務", + "Set Before": "設定編輯前", + "Set Color Label": "設定色彩標籤", + "Set Crop": "設定裁切", + "Set Develop Values": "設定顯像參數", + "Set Flag": "設定旗標", + "Set Mask Adjustments": "設定遮色片調整", + "Set Point Curve": "設定點曲線", + "Set Profile": "設定描述檔", + "Set Rating": "設定評分", + "Set as Album Cover": "設為相簿封面", + "Set as Before": "設為編輯前", + "Set as Reference Photo": "設為參考照片", + "Set as Target Album": "設為目標相簿", + "Set as Target Album (B adds to it)": "設為目標相簿(按 B 加入)", + "Set as Top of Stack": "設為堆疊頂端", + "Set the mix from the photo's colours": "依照片色彩設定混色", + "Settings": "設定", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片使用的初始設定。變更不會影響照片庫中已有的照片。", + "Settings to include": "要包含的設定", + "Settings…": "設定…", + "Shade": "陰影處", + "Shadow": "陰影", + "Shadows": "陰影", + "Shadows Hue": "陰影色相", + "Shadows Luminance": "陰影明度", + "Shadows Saturation": "陰影飽和度", + "Shadows Tint": "陰影色調", + "Shadows split": "陰影分界", + "Sharpen": "銳利化", + "Sharpening": "銳利化", + "Sharpness": "銳利度", + "Short Edge": "短邊", + "Show All": "全部顯示", + "Show Clipping": "顯示溢色", + "Show Deghost Overlay": "顯示去除鬼影疊加", + "Show Hidden Local Locations": "顯示隱藏的本機位置", + "Show Luminance Map": "顯示明度圖", + "Show Mask": "顯示遮色片", + "Show Mask Overlay": "顯示遮色片疊加", + "Show Mask Pins": "顯示遮色片控制點", + "Show Original": "顯示原始照片", + "Show Photo Counts": "顯示照片數量", + "Show Photos with Keyword": "顯示含此關鍵字的照片", + "Show Pins": "顯示控制點", + "Show Source": "顯示來源", + "Show Thumbnails": "顯示縮圖", + "Show destination gamut warning": "顯示目標色域警告", + "Show display gamut warning": "顯示螢幕色域警告", + "Show file names": "顯示檔案名稱", + "Show in Finder": "在 Finder 中顯示", + "Show on Map": "在地圖上顯示", + "Show overlay (O)": "顯示疊加(O)", + "Show ratings, flags and edit badges": "顯示評分、旗標與編輯標記", + "Show the Navigator while zoomed in": "放大時顯示導覽器", + "Show the template tags; click one to insert it": "顯示範本標籤;點擊即可插入", + "Show/Hide Mask": "顯示/隱藏遮色片", + "Show/hide My Photos panel": "顯示/隱藏我的照片面板", + "Shutter speed": "快門速度", + "Side panels": "側邊面板", + "Sidecar names": "附屬檔案名稱", + "Size": "尺寸", + "Size (% of short edge)": "尺寸(短邊百分比)", + "Size limit": "大小上限", + "Skip": "略過", + "Sky": "天空", + "Slideshow": "幻燈片播放", + "Smart Album": "智慧型相簿", + "Smart Album Rule Fields": "智慧型相簿規則欄位", + "Smart Preview Status": "智慧型預覽狀態", + "Smart Previews Location": "智慧型預覽位置", + "Smart previews": "智慧型預覽", + "Smoothness": "平滑度", + "Soft B&W": "柔和黑白", + "Soft Pastel": "柔和粉彩", + "Soft Proofing": "軟式校樣", + "Soft Skin": "柔和膚質", + "Sort": "排序", + "Sort by": "排序依據", + "Source": "來源", + "Source code on GitHub": "GitHub 上的原始碼", + "Spherical": "球面", + "Spiral": "螺旋", + "Split Stack": "分割堆疊", + "Spring Fresh": "清新春日", + "Square Grid": "方格檢視", + "Square Grid (G toggles)": "方格檢視(按 G 切換)", + "Square Grid: show file names and formats": "方格檢視:顯示檔案名稱與格式", + "Stack": "堆疊", + "Stack photos taken within this time of each other:": "將拍攝時間間隔在此範圍內的照片堆疊:", + "Stacks": "堆疊", + "Standard": "標準", + "Start at": "起始值", + "Start number": "起始編號", + "Startup": "啟動", + "State / Province": "州/省", + "Stop": "停止", + "Stop Building Previews": "停止建立預覽", + "Stop Using as Target Album": "停止用作目標相簿", + "Stopping…": "正在停止…", + "Straighten": "拉直", + "Straighten Tool": "拉直工具", + "Strong Contrast": "強烈對比", + "Style": "樣式", + "Subfolder": "子資料夾", + "Subfolder name": "子資料夾名稱", + "Subject": "主體", + "Subtract": "減去", + "Suggestions": "建議", + "Summer Haze": "夏日薄霧", + "Sunday": "星期日", + "Survey": "總覽", + "Survey (N)": "總覽(N)", + "Swap": "交換", + "Swap Aspect (X)": "交換長寬比(X)", + "Swap Before and After Settings": "交換編輯前與編輯後設定", + "Swap Compare Photos": "交換比較照片", + "Switch light / dark theme": "切換淺色/深色主題", + "Sync Settings": "同步設定", + "System Info": "系統資訊", + "System Info…": "系統資訊…", + "System default": "系統預設值", + "Tags": "標籤", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "標籤:請參閱範本旁的「標籤」。檔案及其 XMP 附屬檔案會在磁碟上重新命名;遇到同名檔案會加上 -1、-2…", + "Take a snapshot": "擷取快照", + "Targeted Adjustment": "目標調整", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "目標調整:在照片上向上/下拖曳,調整該位置的色彩", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "目標調整:在照片上向上/下拖曳,調整該位置的曲線", + "Teal & Amber": "青綠與琥珀", + "Teal & Orange": "青綠與橙色", + "Temp": "色溫", + "Temperature": "色溫", + "Template": "範本", + "Temporary session — your library isn't open, and nothing you do here is saved.": "暫存工作階段:照片庫未開啟,此處的操作不會儲存。", + "Text": "文字", + "Text direction": "文字方向", + "Texture": "紋理", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "保留未變更的原始檔案,每個檔案附有記錄編輯設定的 XMP 附屬檔案。", + "The photo has these settings": "此照片具有這些設定", + "Theme": "主題", + "They can be restored from Recently Deleted until it is emptied.": "清空「最近刪除」之前,可以從中還原照片。", + "Thirds": "三分法", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此資料夾無法使用(磁碟可能已中斷連線)。恢復連線或選擇其他資料夾之前,不會建立或使用智慧型預覽。", + "This photo can't be opened": "無法開啟此照片", + "Threshold": "臨界值", + "Thumbnail cache": "縮圖快取", + "Thursday": "星期四", + "Tint": "色調", + "Title": "標題", + "Toggle Keyword from Set": "切換關鍵字集中的關鍵字", + "Toggle Section": "切換區段", + "Toggle Zoom": "切換縮放", + "Tone Curve": "色調曲線", + "Tools": "工具", + "Top Level": "最上層", + "Transfer": "傳輸", + "Treatment": "處理方式", + "Triangle": "三角形", + "Try Again": "再試一次", + "Tuesday": "星期二", + "Tungsten": "鎢絲燈", + "Turn Off": "關閉", + "Turn on Include subfolders, or pick another folder under Local": "啟用「包含子資料夾」,或在本機中選擇其他資料夾", + "Type": "類型", + "Uncheck All": "全部取消勾選", + "Undo": "復原", + "Unedited": "未編輯", + "Unflag": "移除旗標", + "Unflagged": "無旗標", + "Ungroup Stack": "取消堆疊", + "Unknown Date": "未知日期", + "Unrated": "未評分", + "Unreadable": "無法讀取", + "Update": "更新", + "Update Mask Shape": "更新遮色片形狀", + "Update Preset with Current Settings": "以目前設定更新預設集", + "Update Rules from Current Filter": "依目前篩選條件更新規則", + "Update Version": "更新版本", + "Update Version with Current Settings": "以目前設定更新版本", + "Update with Current Settings": "以目前設定更新", + "Upright": "透視校正", + "Urban": "城市", + "Use Keyword Set": "使用關鍵字集", + "Use Library Folder": "使用照片庫資料夾", + "Use camera-specific defaults": "使用個別相機的預設值", + "Use the GPU for rendering": "使用 GPU 算繪", + "User Presets": "使用者預設集", + "Value": "數值", + "Variance": "變異數", + "Version": "版本", + "Version name": "版本名稱", + "Versions": "版本", + "Versions (Shift+V)": "版本(Shift+V)", + "Vertical": "垂直", + "Vertical text": "垂直文字", + "Vibrance": "自然飽和度", + "Videos": "影片", + "View": "檢視", + "Vignette": "暗角", + "Vignetting": "暗角", + "Vintage": "復古", + "Virtual Copy": "虛擬副本", + "Visible": "可見照片", + "Visualize Spots": "顯示斑點", + "Visualize Spots (A)": "顯示斑點(A)", + "Visualize range": "顯示範圍", + "Vivid": "鮮豔", + "Vivid Pop": "鮮明色彩", + "Warm Film": "暖調底片", + "Warm Glow": "暖光", + "Warm Print": "暖調沖印", + "Warm Table": "溫暖餐桌", + "Watched folder": "監看的資料夾", + "Watermark": "浮水印", + "Wednesday": "星期三", + "What's New": "新增功能", + "When rated or hovered": "評分或游標停留時", + "White Balance": "白平衡", + "White Balance Selector": "白平衡選取器", + "White Balance Selector (W)": "白平衡選取器(W)", + "Whites": "白色色階", + "Width": "寬度", + "Width (% of photo)": "寬度(照片百分比)", + "Width (px)": "寬度(像素)", + "Width × Height": "寬度 × 高度", + "Window": "視窗", + "Winter Blue": "冬日藍調", + "XMP Sidecar Preferences": "XMP 附屬檔案偏好設定", + "XMP sidecars": "XMP 附屬檔案", + "Year": "年", + "Yellow": "黃色", + "Yellow Hue": "黃色色相", + "Yellow Luminance": "黃色明度", + "Yellow Saturation": "黃色飽和度", + "Yes": "是", + "Your library couldn't be opened": "無法開啟你的照片庫", + "Zoom": "縮放", + "Zoom 100%": "縮放至 100%", + "Zoom In": "放大", + "Zoom Out": "縮小", + "Zoom to Fit": "縮放至符合視窗", + "Zoom to page level": "符合頁面大小", + "and": "且", + "comma, separated": "以逗號分隔", + "memory only": "僅記憶體", + "none": "無", + "not available here": "此處無法使用", + "of the following rules:": "下列規則:", + "this photo": "這張照片", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)" +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9e1f09252..4dff9089b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -267,6 +267,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest u.dialog = app.ui.dialog.clone(); u.status = app.ui.status.clone(); app.ui = u; + crate::i18n::set_language(app.ui.language); ctx.request_repaint(); ok(serde_json::to_value(&app.ui).unwrap_or_default()) } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 0e2389a44..bf5aa507c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -3,7 +3,7 @@ use std::{cell::Cell, collections::BTreeMap, sync::OnceLock}; use serde::{Deserialize, Serialize}; -include!(concat!(env!("OUT_DIR"), "/ja-formats.rs")); +include!(concat!(env!("OUT_DIR"), "/formats.rs")); #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Language { @@ -12,25 +12,33 @@ pub enum Language { En, #[serde(rename = "ja")] Ja, + #[serde(rename = "zh-tw", alias = "zh-TW", alias = "zh-Hant", alias = "zh-hant")] + ZhTw, } impl Language { - pub const ALL: [Self; 2] = [Self::En, Self::Ja]; + pub const ALL: [Self; 3] = [Self::En, Self::Ja, Self::ZhTw]; pub fn name(self) -> &'static str { match self { Self::En => "English", Self::Ja => "日本語", + Self::ZhTw => "繁體中文(台灣)", } } pub fn parse(code: &str) -> Option { - match code { + match code.to_ascii_lowercase().as_str() { "en" => Some(Self::En), "ja" => Some(Self::Ja), + "zh-tw" | "zh-hant" => Some(Self::ZhTw), _ => None, } } pub fn tr(self, source: &str) -> &str { - if self == Self::Ja { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source } + match self { + Self::En => source, + Self::Ja => japanese().get(source).map(String::as_str).unwrap_or(source), + Self::ZhTw => traditional_chinese().get(source).map(String::as_str).unwrap_or(source), + } } } @@ -39,15 +47,20 @@ thread_local! { } pub fn default_language() -> Language { - if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En } + std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|code| Language::parse(&code)).unwrap_or_default() } pub fn set_language(language: Language) { LANGUAGE.with(|value| value.set(language)); } +pub fn language() -> Language { + LANGUAGE.with(Cell::get) +} + +/// Kept for callers of the original two-language API. pub fn is_japanese() -> bool { - LANGUAGE.with(|value| value.get() == Language::Ja) + language() == Language::Ja } fn japanese() -> &'static BTreeMap { @@ -60,16 +73,255 @@ fn japanese() -> &'static BTreeMap { }) } +fn traditional_chinese() -> &'static BTreeMap { + static MESSAGES: OnceLock> = OnceLock::new(); + MESSAGES.get_or_init(|| { + serde_json::from_str(include_str!("../locales/zh-tw.json")).unwrap_or_else(|error| { + log::error!("Invalid Traditional Chinese message catalog: {error}"); + BTreeMap::new() + }) + }) +} + /// Translate a built-in display label, preserving unknown labels verbatim. /// Never call this on editable user text, filenames or command identifiers. pub fn tr(source: &str) -> &str { - if is_japanese() { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source } + language().tr(source) +} + +/// Display names from stock presets/profiles; imported and editable names remain verbatim. +pub fn builtin_label(source: &str, builtin: bool) -> &str { + if builtin { tr(source) } else { source } +} + +/// Display-only history labels. Preserve custom preset names within generated steps. +pub fn history_label<'a>(source: &'a str, presets: &'a [lightcraft_develop::Preset]) -> String { + if let Some(name) = source.strip_prefix("Preset: ") { + let builtin = presets.iter().any(|p| p.builtin && p.name == name) && !presets.iter().any(|p| !p.builtin && p.name == name); + return tr_format!("Preset: {name}", name = builtin_label(name, builtin)); + } + if let Some(name) = source.strip_prefix("Reset ") { + return tr_format!("Reset {name}", name = tr(name)); + } + if let Some(name) = source.strip_prefix("Quick Develop: ") { + return tr_format!("Quick Develop: {name}", name = tr(name)); + } + tr(source).to_string() +} + +/// Built-in library headings, without translating an album's editable name. +pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcraft_catalog::Catalog) -> String { + let label = source.label(catalog); + if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() } +} + +/// Calendar headings for the grid and the narrower date sidebar. ISO keys stay unchanged. +pub fn date_group_label(key: &str, short: bool) -> String { + let label = lightcraft_catalog::dates::group_label(key); + if language() != Language::ZhTw { + return if short { + match key.len() { + 7 => label.split(' ').next().unwrap_or(&label).to_string(), + 10 => label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(), + _ => label, + } + } else { + label + }; + } + let Some(year) = key.get(..4).and_then(|y| y.parse::().ok()) else { return tr(&label).to_string() }; + if key.len() == 4 { + return tr_format!("{year}", year = year); + } + let Some(month) = key.get(5..7).and_then(|m| m.parse::().ok()).filter(|m| (1..=12).contains(m)) else { + return tr(&label).to_string(); + }; + if key.len() == 7 { + return if short { tr_format!("{month}", month = month) } else { tr_format!("{month} {year}", month = month, year = year) }; + } + if key.len() == 10 + && let Some(weekday) = lightcraft_catalog::dates::weekday(key) + && let Some(day) = key.get(8..10).and_then(|d| d.parse::().ok()) + { + let weekday = tr(weekday); + return if short { + tr_format!("{weekday}, {day}", weekday = weekday, day = day) + } else { + tr_format!("{weekday}, {day} {month} {year}", weekday = weekday, day = day, month = month, year = year) + }; + } + tr(&label).to_string() +} + +/// Localized capture times for display; the source metadata is never rewritten. +pub fn display_time(iso: &str) -> String { + if language() != Language::ZhTw || lightcraft_catalog::dates::normalize_iso(iso).is_none() { + return lightcraft_catalog::dates::display_time(iso); + } + let date = date_group_label(iso.get(..10).unwrap_or(iso), false); + match iso.get(11..).filter(|time| !time.is_empty()) { + Some(time) => format!("{date} {time}"), + None => date, + } } #[cfg(test)] mod tests { use super::*; + #[test] + fn traditional_chinese_catalog_covers_commands_and_japanese_messages() { + let messages = traditional_chinese(); + for key in japanese().keys() { + assert!(messages.get(key).is_some_and(|value| !value.is_empty()), "missing: {key}"); + } + for preset in lightcraft_engine::presets::builtin() { + for key in [&preset.name, &preset.group] { + assert!(messages.contains_key(key), "missing built-in preset label: {key}"); + } + } + for profile in lightcraft_engine::presets::PROFILES { + for key in [profile.name, profile.group] { + assert!(messages.contains_key(key), "missing profile label: {key}"); + } + } + for command in lightcraft_engine::command_specs() { + assert!(messages.contains_key(command.label), "missing command: {}", command.label); + } + for (_, label, _, _) in crate::menus::UI_COMMANDS { + assert!(messages.contains_key(*label), "missing UI command: {label}"); + } + } + + #[test] + fn traditional_chinese_switches_through_menu_and_control_and_persists() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::ZhTw); + assert_eq!(tr("File"), "檔案"); + assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片"); + assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); + assert_eq!(date_group_label("2026-09-20", true), "20日 星期日"); + assert_eq!(date_group_label("2026-09", false), "2026年9月"); + assert_eq!(date_group_label("2026-09", true), "9月"); + assert_eq!(date_group_label("2026", false), "2026年"); + assert_eq!(date_group_label("", false), "未知日期"); + assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05"); + assert_eq!(display_time("a-user-value"), "a-user-value"); + assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true)); + assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false)); + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] { + assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color"); + } + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩"); + assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Language::ZhTw); + let ctx = egui::Context::default(); + for code in ["zh-tw", "zh-TW", "zh-hant", "zh-Hant"] { + assert_eq!(Language::parse(code), Some(Language::ZhTw)); + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(app.ui.language, Language::ZhTw); + } + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected reply") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.ui.language, Language::ZhTw); + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); + let chinese_ids = ids(&app); + app.run("app.language.english", serde_json::json!({})).unwrap(); + assert_eq!(tr("File"), "File"); + assert_eq!(chinese_ids, ids(&app)); + } + + #[test] + fn traditional_chinese_presets_and_panel_headers_are_painted() { + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.language = Language::ZhTw; + app.ui.presets = true; + let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone(); + app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin }); + for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] { + app.ui.right = panel; + let text = painted_text(&mut app); + assert!(text.contains(title), "{text}"); + assert!(!text.contains("History"), "{text}"); + assert!(!text.contains("Versions"), "{text}"); + assert!(text.contains("暖光"), "built-in preset: {text}"); + assert!(text.contains("Warm Glow"), "user preset: {text}"); + assert!(text.contains("色彩"), "built-in group: {text}"); + assert!(text.contains("Color"), "user group: {text}"); + } + assert_eq!(history_label("Exposure", &app.session.presets), "曝光"); + assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片"); + assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow"); + set_language(Language::En); + } + + fn painted_text(app: &mut crate::LightcraftApp) -> String { + fn collect(shape: &egui::epaint::Shape, text: &mut String) { + match shape { + egui::epaint::Shape::Text(shape) => { + text.push_str(&shape.galley.job.text); + text.push('\n'); + } + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut text = String::new(); + for frame in 0..4 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + text.clear(); + for shape in out.shapes { + collect(&shape.shape, &mut text); + } + } + text + } + + #[test] + fn traditional_chinese_fonts_cover_static_and_dynamic_catalogs() { + if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() { + eprintln!("skipped glyph coverage: build with CRAFT_FONTS_DIR"); + return; + } + let formats: BTreeMap = serde_json::from_str(include_str!("../locales/zh-tw-formats.json")).unwrap(); + let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); + ctx.fonts_mut(|fonts| { + for family in ui_families() { + let font = egui::FontId::new(13.0, family); + let mut missing = std::collections::BTreeSet::new(); + for message in traditional_chinese().values().chain(formats.values()).map(String::as_str).chain([Language::ZhTw.name()]) { + // egui reports false for glyphs of Monospace's first (replacement) + // face, Hack. Check its CJK fallback; proportional/semibold check all text. + for ch in message.chars().filter(|ch| !ch.is_whitespace()) { + if font.family == egui::FontFamily::Monospace && !('\u{4e00}'..='\u{9fff}').contains(&ch) { + continue; + } + if !fonts.has_glyph(&font, ch) { + missing.insert(ch); + } + } + } + assert!(missing.is_empty(), "Missing glyphs in {font:?}: {missing:?}"); + } + }); + } + #[test] fn catalog_is_valid_and_contains_core_workflows() { let messages: BTreeMap = serde_json::from_str(include_str!("../locales/ja.json")).unwrap(); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index f122041e0..ef422d792 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -272,6 +272,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { match id { "app.language.english" => Some(u.language == crate::i18n::Language::En), "app.language.japanese" => Some(u.language == crate::i18n::Language::Ja), + "app.language.traditionalChinese" => Some(u.language == crate::i18n::Language::ZhTw), "develop.autoSync" => Some(app.session.auto_sync), "view.photoCounts" => Some(u.show_counts), "view.secondWindow" => Some(u.second_window), @@ -316,12 +317,18 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String { let n = app.session.selection.ids.len(); match id { - "edit.undo" => { - app.session.undo.last().map(|e| crate::i18n::tr_format!("Undo {}", crate::i18n::tr(&e.label))).unwrap_or_else(|| "Undo".into()) - } - "edit.redo" => { - app.session.redo.last().map(|e| crate::i18n::tr_format!("Redo {}", crate::i18n::tr(&e.label))).unwrap_or_else(|| "Redo".into()) - } + "edit.undo" => app + .session + .undo + .last() + .map(|e| crate::i18n::tr_format!("Undo {}", crate::i18n::history_label(&e.label, &app.session.presets))) + .unwrap_or_else(|| "Undo".into()), + "edit.redo" => app + .session + .redo + .last() + .map(|e| crate::i18n::tr_format!("Redo {}", crate::i18n::history_label(&e.label, &app.session.presets))) + .unwrap_or_else(|| "Redo".into()), "photo.delete" if n > 1 => crate::i18n::tr_format!("Delete {n} Photos", n = n), "photo.virtualCopy" if n > 1 => crate::i18n::tr_format!("Create {n} Virtual Copies", n = n), "dialog.rename" if n > 1 => crate::i18n::tr_format!("Rename {n} Photos…", n = n), @@ -341,19 +348,26 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { item( "library.buildPreviews", json!({"size": "standard", "edge": app.ui.settings.preview_edge}), - format!("Build Standard-Sized Previews ({scope})"), + crate::i18n::tr_format!("Build Standard-Sized Previews ({scope})", scope = crate::i18n::tr(scope)), + None, + !running, + None, + ), + item( + "library.buildPreviews", + json!({"size": "full"}), + crate::i18n::tr_format!("Build 1:1 Previews ({scope})", scope = crate::i18n::tr(scope)), None, !running, None, ), - item("library.buildPreviews", json!({"size": "full"}), format!("Build 1:1 Previews ({scope})"), None, !running, None), item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None), MenuNode::Separator, // (read and written on a worker thread: the originals may be on a slow drive) item( "library.smartPreviews", json!({"background": true}), - format!("Build Smart Previews ({scope})"), + crate::i18n::tr_format!("Build Smart Previews ({scope})", scope = crate::i18n::tr(scope)), None, app.session.media.smart_dir.is_some() && !running, None, @@ -361,7 +375,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { item( "library.smartPreviews", json!({"discard": true, "background": true}), - format!("Discard Smart Previews ({scope})"), + crate::i18n::tr_format!("Discard Smart Previews ({scope})", scope = crate::i18n::tr(scope)), None, app.session.media.smart_dir.is_some() && !running, None, @@ -394,7 +408,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { .map(|(l, sc)| { let name = format!("{l:?}"); let label = match app.session.catalog.custom_label_name(*l) { - Some(custom) => format!("{custom} ({name})"), + Some(custom) => format!("{custom} ({})", crate::i18n::tr(&name)), None => name.clone(), }; item( @@ -418,7 +432,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.push(item( "label.applySet", json!({"name": name}), - format!("Label Set: {name}"), + crate::i18n::tr_format!("Label Set: {name}", name = name), None, true, Some(current.as_deref() == Some(name)), @@ -499,7 +513,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { item( "library.selectBy", json!({"label": name.to_lowercase()}), - crate::i18n::tr_format!("{name} Label", name = name), + crate::i18n::tr_format!("{name} Label", name = crate::i18n::tr(&name)), None, true, None, @@ -516,7 +530,8 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.push(MenuNode::Separator); } builtin = b; - v.push(item("app.export", json!({"preset": p.name, "background": true}), p.name, None, sel, None)); + let label = crate::i18n::builtin_label(&p.name, b).to_string(); + v.push(item("app.export", json!({"preset": p.name, "background": true}), label, None, sel, None)); } v.push(MenuNode::Separator); v.push(item("dialog.export", Value::Null, "Custom…", None, sel, None)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5d838b759..e3db49dc8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -13,6 +13,7 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static pub const UI_COMMANDS: &[UiCommand] = &[ ("app.language.english", "English", None, "Edit>Language"), ("app.language.japanese", "日本語", None, "Edit>Language"), + ("app.language.traditionalChinese", "繁體中文(台灣)", None, "Edit>Language"), ("view.photoGrid", "Photo Grid", None, "View"), ("view.squareGrid", "Square Grid", None, "View"), // G: Photo Grid ↔ Square Grid (from other views: the photo grid) @@ -151,10 +152,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { if app.ui.right == p { app.ui.right = RightPanel::None; - app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = name)); + app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name))); } else { app.ui.right = p; - app.toast(ctx, crate::i18n::tr_format!("{name} On", name = name)); + app.toast(ctx, crate::i18n::tr_format!("{name} On", name = crate::i18n::tr(name))); if p.is_edit_tool() && !matches!(app.ui.view, ViewMode::Detail) { app.ui.view = ViewMode::Detail; } @@ -206,8 +207,13 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { - if matches!(id, "app.language.english" | "app.language.japanese") { - app.ui.language = if id == "app.language.japanese" { crate::i18n::Language::Ja } else { crate::i18n::Language::En }; + if matches!(id, "app.language.english" | "app.language.japanese" | "app.language.traditionalChinese") { + app.ui.language = match id { + "app.language.japanese" => crate::i18n::Language::Ja, + "app.language.traditionalChinese" => crate::i18n::Language::ZhTw, + _ => crate::i18n::Language::En, + }; + crate::i18n::set_language(app.ui.language); return Some(Ok(json!(app.ui.language))); } let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fe8b04781..710b5d4e1 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -466,7 +466,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.separator(); } builtin = b; - if ui.selectable_label(false, &p.name).clicked() { + if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() { chosen = Some(p.name); } } @@ -701,7 +701,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { opts } => crate::import::body(app, ui, opts), Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab), Dialog::ConfirmDelete { count } => { - let what = if *count == 1 { "this photo".to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; + let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); } @@ -710,7 +710,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits())); ui.add_space(10.0); - let discord = egui::Button::new(egui::RichText::new("Join the ArtCraft Discord").font(t.semibold(15.0)).color(egui::Color32::WHITE)) + let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE)) .fill(t.accent) .min_size(egui::vec2(260.0, 34.0)); let r = ui.add(discord).on_hover_text(crate::links::DISCORD); @@ -724,7 +724,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ("Source code on GitHub", crate::links::GITHUB), ("ArtCraft — more creative apps", crate::links::WEBSITE), ] { - let r = ui.link(label).on_hover_text(url); + let r = ui.link(crate::i18n::tr(label)).on_hover_text(url); if r.clicked() { let _ = crate::links::open(app, url); } @@ -776,7 +776,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { opts } => { let n = opts.selected_paths().len(); let verb = if opts.copy && opts.move_files { "Move" } else { "Import" }; - add_label = format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }); + add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); add_label.as_str() } Dialog::Merge { .. } => "Merge", diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ff9d9a261..cddb984e1 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -140,8 +140,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim)); let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); - let r = - crate::widgets::dropdown(ui, "profile", if name == "Color" { crate::i18n::tr("Color") } else { name }, t.font(15.0), t.text_label); + let r = crate::widgets::dropdown(ui, "profile", crate::i18n::tr(name), t.font(15.0), t.text_label); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() { @@ -356,7 +355,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) key: &str, p: &'static lightcraft_engine::presets::ProfileInfo, pick: &mut (Option<&'static str>, Option<&'static str>)| { - let r = ui.selectable_label(p.id == cur, p.name); + let r = ui.selectable_label(p.id == cur, crate::i18n::tr(p.name)); register(ui.ctx(), format!("profileMenu:{key}:{}", p.id), r.rect); if r.clicked() { pick.0 = Some(p.id); @@ -366,7 +365,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } }; let heading = |ui: &mut egui::Ui, s: &str| { - ui.label(egui::RichText::new(s).font(t.semibold(11.5)).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim)); }; for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] { let list: Vec<_> = ids.iter().filter_map(|id| profile(id)).collect(); @@ -380,7 +379,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.separator(); } for g in profile_groups() { - let r = ui.menu_button(g, |ui| { + let r = ui.menu_button(crate::i18n::tr(g), |ui| { ui.set_min_width(170.0); for p in PROFILES.iter().filter(|p| p.group == g) { item(ui, "group", p, &mut pick); @@ -392,9 +391,9 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) if let Some(p) = profile(cur) { let fav = app.session.profile_favorites.iter().any(|f| f == p.id); let label = if fav { - crate::i18n::tr_format!("Remove “{}” from Favorites", p.name) + crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::tr(p.name)) } else { - crate::i18n::tr_format!("Add “{}” to Favorites", p.name) + crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::tr(p.name)) }; let r = ui.button(label); register(ui.ctx(), "profileMenu:toggleFavorite", r.rect); @@ -414,7 +413,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) let mut s = d.clone(); s.profile.id = p.id.to_string(); s.profile.amount = 100.0; - app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s }); + app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s }); } if let Some(id) = pick.0 { let _ = app.run("develop.profile", json!({"id": id})); @@ -811,7 +810,9 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.horizontal(|ui| { ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label)); let current = matching_preset(&app.session, &d.curve); - let r = crate::widgets::dropdown(ui, "curvePreset", current.as_deref().unwrap_or("Custom"), t.font(12.0), t.text); + let builtin = current.as_ref().is_none_or(|name| all_presets(&app.session).iter().any(|p| p.builtin && &p.name == name)); + let label = crate::i18n::builtin_label(current.as_deref().unwrap_or("Custom"), builtin); + let r = crate::widgets::dropdown(ui, "curvePreset", label, t.font(12.0), t.text); egui::Popup::menu(&r).show(|ui| { ui.set_min_width(180.0); let presets = all_presets(&app.session); @@ -821,7 +822,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) user_seen = true; ui.separator(); } - let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), &p.name); + let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)); register(ui.ctx(), format!("curvePreset:{}", p.name), r.rect); if r.clicked() { let _ = app.run("curve.applyPreset", json!({"name": p.name})); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 760abbfe0..f9fe79db8 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -150,7 +150,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { folder_header(app, ui, hr, &b, &ids, local, &cnt) } None => { - let title = app.session.source.label(&app.session.catalog); + let title = crate::i18n::source_label(app.session.source, &app.session.catalog); ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text); ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim); } @@ -164,7 +164,11 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !chips.is_empty() { let body = match total { Some(n) if n > 0 => { - crate::i18n::tr_format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog), n = n) + crate::i18n::tr_format!( + "{n} photos in {} are hidden by the filters above", + crate::i18n::source_label(app.session.source, &app.session.catalog), + n = n + ) } _ => "Remove a filter above, or choose Clear all".to_string(), }; @@ -409,7 +413,7 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: p.rect_filled(r, 0.0, t.canvas); p.hline(r.x_range(), r.bottom(), Stroke::new(1.0, t.divider)); } - let g = p.layout_no_wrap(run.label.clone(), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label }); + let g = p.layout_no_wrap(crate::i18n::date_group_label(&run.key, false), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label }); let x = r.left() + 8.0; let gw = g.size().x; p.galley(pos2(x, r.center().y - g.size().y / 2.0), g, t.text); @@ -520,7 +524,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square .collect(); if parts.is_empty() { "—".to_string() } else { parts.join(" · ") } } - "date" => photo.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "No date".into()), + "date" => photo.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| crate::i18n::tr("No date").into()), _ => photo.file_name.rsplit_once('.').map(|(n, _)| n.to_string()).unwrap_or(photo.file_name.clone()), }; p.text(pos2(r.left() + 8.0, r.top() + 12.0), Align2::LEFT_CENTER, name, t.font(10.5), t.text_dim); @@ -786,7 +790,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.separator(); ui.menu_button(crate::i18n::tr("Set Rating"), |ui| { for r in 0..=5 { - if ui.button(if r == 0 { "No Stars".to_string() } else { "★".repeat(r) }).clicked() { + if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() { let _ = app.run("photo.rate", json!({"rating": r})); } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 7d7037435..760eefde8 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -120,14 +120,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("By Date"), t.semibold(13.5), t.text_label); for g in app.caches.date_groups(&app.session.catalog).iter() { // year → month → day; a click filters by that prefix, the triangle opens a level - if date_row(app, ui, &g.year, &g.year, g.count, 0.0) { + if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) { for (m, n) in &g.months { - let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string(); + let label = crate::i18n::date_group_label(m, true); if date_row(app, ui, m, &label, *n, 16.0) { for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { - let label = lightcraft_catalog::dates::group_label(d); - // "Sunday, 20 September 2026" → "Sunday, 20" - let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(); + let label = crate::i18n::date_group_label(d, true); date_row(app, ui, d, &label, *n, 32.0); } } diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 20a095e03..0a7351bab 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -80,22 +80,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let row_h = if thumbs { 54.0 } else { 26.0 }; let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - let mut groups: BTreeMap> = BTreeMap::new(); + let mut groups: BTreeMap<(String, bool), Vec> = BTreeMap::new(); for p in &app.session.presets { - groups.entry(p.group.clone()).or_default().push(p.clone()); + groups.entry((p.group.clone(), p.builtin)).or_default().push(p.clone()); } - for (g, items) in groups { - let open_id = egui::Id::new(("preset-group", &g)); + for ((g, builtin), items) in groups { + let open_id = egui::Id::new(("preset-group", &g, builtin)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); - register(ui.ctx(), format!("presetGroup:{g}"), r); + let shared_group = app.session.presets.iter().any(|p| p.group == g && p.builtin != builtin); + register(ui.ctx(), if builtin || !shared_group { format!("presetGroup:{g}") } else { format!("presetGroup:user:{g}") }, r); paint( ui.painter(), Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label, ); - ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, &g, t.semibold(13.0), t.text_label); + ui.painter().text( + pos2(r.left() + 36.0, r.center().y), + Align2::LEFT_CENTER, + crate::i18n::builtin_label(&g, builtin), + t.semibold(13.0), + t.text_label, + ); if resp.clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); } @@ -108,7 +115,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { continue; } for pr in items { - let (pid, name, fav) = (pr.id.clone(), pr.name.clone(), pr.favorite); + let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); register(ui.ctx(), format!("preset:{pid}"), r); let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid); @@ -156,7 +163,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let (builtin, group) = (pr.builtin, pr.group.clone()); resp.context_menu(|ui| { - if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() { + if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() { let _ = app.run("preset.favorite", json!({"id": pid})); } if builtin { @@ -168,7 +175,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .clicked() { match app.run("preset.update", json!({"id": pid})) { - Ok(_) => app.toast(ui.ctx(), format!("Updated “{name}”")), + Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)), Err(e) => app.toast(ui.ctx(), e), } } diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 7633cdef6..ec4ddc57b 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -35,7 +35,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); let name = profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Profile"), t.font(13.0), t.text_dim); - ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, name, t.font(14.0), t.text_label); + ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr(name), t.font(14.0), t.text_label); if d.profile.id != "lc.color" { super::edit::control(app, ui, &d, "profile.amount", true); ui.add_space(6.0); @@ -60,7 +60,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label, ); - ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label); + ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label); ui.painter().text(pos2(r.right() - 22.0, r.center().y), Align2::RIGHT_CENTER, list.len().to_string(), t.font(12.0), t.text_dim); if resp.clicked() { ui.data_mut(|m| m.insert_temp(open_id, !open)); @@ -90,7 +90,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { && let Some(p) = profile(&saved_profile_id) { let s = with_profile(&d, p); - app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s }); + app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s }); } } else { ui.data_mut(|m| m.remove::<(PhotoId, String)>(last_hover_id)); @@ -129,7 +129,7 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); } let name_color = if applied { t.text } else { t.text_label }; - painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, p.name, t.font(12.0), name_color); + painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, crate::i18n::tr(p.name), t.font(12.0), name_color); // favourite star (always shown when set, on hover otherwise) let fav = app.session.profile_favorites.iter().any(|f| f == p.id); let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0)); @@ -140,10 +140,10 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE }); } if resp.hovered() && !applied && !over_star { - app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s.clone() }); + app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s.clone() }); ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p.id.to_string()))); } - let resp = resp.on_hover_text(format!("{} ({})", p.name, p.group)); + let resp = resp.on_hover_text(format!("{} ({})", crate::i18n::tr(p.name), crate::i18n::tr(p.group))); if resp.clicked() { if resp.interact_pointer_pos().is_some_and(|q| star.contains(q)) { let _ = app.run("profile.favorite", json!({"id": p.id})); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 1a07b5c6b..fcbb6abb6 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -48,7 +48,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { pub fn header(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); - ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, title, t.semibold(15.0), t.text); + ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(15.0), t.text); } pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) { @@ -69,7 +69,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Aspect Ratio")); let cur = d.crop.aspect.map(|(w, h)| format!("{} × {}", w as f64 / 100.0, h as f64 / 100.0)).unwrap_or_else(|| "Free".into()); - let r = ui.add(egui::Button::new(cur).frame(false)); + let r = ui.add(egui::Button::new(crate::i18n::tr(&cur)).frame(false)); register(ui.ctx(), "button:cropAspect", r.rect); egui::Popup::menu(&r).show(|ui| { for (label, a) in [ @@ -84,7 +84,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("16 × 9", "16x9"), ("16 × 10", "16x10"), ] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { let _ = app.run("crop.aspect", json!({"aspect": a})); } } @@ -494,7 +494,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() { let _ = app.run("dialog.captureTime", json!({})); } - let cap = p.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "—".into()); + let cap = p.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| "—".into()); ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate()); }); @@ -651,7 +651,7 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); let id = egui::Id::new(("info-field", key)); let mut text: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string()); let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) }; @@ -830,7 +830,8 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false); let named_n = p.versions.iter().filter(|v| !v.auto).count(); let items = [("Named", "named"), ("Auto", "auto")]; - let labels = [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), format!("Auto ({})", p.versions.len() - named_n)]; + let labels = + [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), crate::i18n::tr_format!("Auto ({})", p.versions.len() - named_n)]; let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect(); if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) { auto = i == 1; @@ -840,7 +841,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect(); if shown.is_empty() { let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." }; - ui.label(egui::RichText::new(msg).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr(msg)).color(t.text_dim)); } // newest first for (i, v) in shown.into_iter().rev() { @@ -907,6 +908,9 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { /// "Sep 30, 2026, 12:00 PM" from an ISO time. fn short_time(iso: &str) -> String { + if crate::i18n::language() == crate::i18n::Language::ZhTw { + return crate::i18n::display_time(iso); + } let long = lightcraft_catalog::dates::display_time(iso); // "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t))); @@ -935,7 +939,13 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.painter().rect_filled(r, 3.0, t.hover); } paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon); - ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, &h.label, t.font(12.5), t.text_label); + ui.painter().text( + pos2(r.left() + 24.0, r.center().y), + Align2::LEFT_CENTER, + crate::i18n::history_label(&h.label, &app.session.presets), + t.font(12.5), + t.text_label, + ); if resp.clicked() { let _ = app.run("history.restore", json!({"index": i})); } diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index a380090e1..5d2582e0b 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -91,7 +91,7 @@ fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Language"), |ui| { - choices(ui, "settingsLanguage", &[(crate::i18n::Language::Ja, "日本語"), (crate::i18n::Language::En, "English")], &mut app.ui.language); + choices(ui, "settingsLanguage", &crate::i18n::Language::ALL.map(|language| (language, language.name())), &mut app.ui.language); crate::i18n::set_language(app.ui.language); }); heading(ui, t, crate::i18n::tr("Library")); diff --git a/docs/localization-zh-tw.md b/docs/localization-zh-tw.md new file mode 100644 index 000000000..3b20b3757 --- /dev/null +++ b/docs/localization-zh-tw.md @@ -0,0 +1,46 @@ +# LightCraft 繁體中文(台灣)介面 + +從「編輯 → 語言 → 繁體中文(台灣)」切換,或開啟「設定 → 一般 → 語言」選擇。 +macOS 的設定也可由「LightCraft → 設定…」或 `⌘,` 開啟。 +語言立即生效,並儲存在應用程式設定的 `ui.json`,下次啟動會沿用。 +英文、日文與繁體中文可隨時互相切換。 + +繁中翻譯涵蓋 macOS 原生選單、程式內選單與子選單、鍵盤快速鍵說明、照片編輯、 +裁切、遮色片、匯入/匯出、設定、41 個內建編輯預設集與分類、內建描述檔、 +曲線與匯出預設集、歷史紀錄/版本面板、內建照片來源標題、日期群組與拍攝時間、 +照片庫開啟失敗提示及主要處理進度。 +切換介面不會變更操作指令 ID、照片檔名、使用者命名的相簿、預設集或中繼資料。 +未翻譯的技術錯誤、發行說明及部分新增文案仍使用英文。 + +## 建置與字型 + +使用既有 [craft-fonts](https://github.com/storytold/craft-fonts) 字型作為建置輸入: + +```sh +CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft +``` + +桌面版以現有日本語字型補足繁中文字形,不在此儲存庫新增字型檔。 +未指定 `CRAFT_FONTS_DIR` 的建置缺少 CJK 字形。 +Web 建置僅嵌入 BIZ UDPGothic Regular,尚未驗證完整繁中文字形覆蓋。 + +## 自動化與維護 + +`LIGHTCRAFT_LANGUAGE=zh-tw lightcraft-cli snapshot --demo -o zh-tw.png` 可繪製繁中畫面。 +接受 `zh-tw`、`zh-TW`、`zh-hant` 與 `zh-Hant`;儲存時統一使用 `zh-tw`。 +控制通道可切換語言: + +```json +{"method":"ui.set","params":{"language":"zh-tw"}} +{"method":"ui.menu.invoke","params":{"command":"app.language.traditionalChinese"}} +``` + +靜態文案位於 `crates/ui-egui/locales/zh-tw.json`,含變數的訊息位於 +`crates/ui-egui/locales/zh-tw-formats.json`。動態訊息由 Rust 在建置時檢查格式參數, +須與 `ja-formats.json` 保持相同英文鍵。`{:.0}` 消耗英文複數字尾引數,繁中不顯示該字尾。 + +```sh +CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests +``` + +測試涵蓋翻譯目錄、指令/選單文字、語言切換、設定保存、資料名稱保持原樣及桌面字形。 diff --git a/docs/parity.md b/docs/parity.md index fc94d08a7..c8693534d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -503,7 +503,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization-ja.md` | Japanese/English UI with persisted language preference and Japanese regular/bold fonts; menus, edit, crop/masks, settings, import/export and primary progress messages. Technical errors/release notes and other locales remain untranslated. | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization-ja.md`, `docs/localization-zh-tw.md`, `cmd:app.language.traditionalChinese` | English/Japanese/Traditional Chinese UI with persisted language preference and existing CJK regular/bold fonts; menus, edit, crop/masks, settings, import/export and primary progress messages. Native macOS and in-window menus plus keyboard reference translated. Technical errors/release notes and other locales remain untranslated; full Traditional Chinese web glyph coverage unverified. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | From 51f0ef25ae7a2b5958e7ad83afcfabb646226f07 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 05:41:28 -0400 Subject: [PATCH 21/30] Export: look up the library's symlinked originals once per guard The symlink fallback ran read_link on every library entry for each existing target; a large library made every Overwrite export check do thousands of syscalls. The guard now finds the links on first need and keeps them. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/originals.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index 130d22a20..b3a140a2b 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -15,6 +15,8 @@ use lightcraft_catalog::{Catalog, Source}; pub struct OriginalGuard { /// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …) by_name: HashMap>, + /// The entries of `by_name` that are symlinks, found on the first check that needs them. + links: std::sync::OnceLock>, } impl OriginalGuard { @@ -32,6 +34,7 @@ impl OriginalGuard { /// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`). pub fn add(&mut self, path: &Path, label: &str) { let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return }; + self.links = std::sync::OnceLock::new(); let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what)); put(name.clone(), path.to_path_buf(), format!("the original of {label}")); let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); @@ -60,9 +63,11 @@ impl OriginalGuard { } // An imported symlink can have a different name from its original. A write to // that original would change what the library reads through the link. Only do - // this fallback for existing targets that the name index did not protect. - for (p, what) in self.by_name.values().flatten() { - if std::fs::read_link(p).is_ok() && same_file(p, target) { + // this fallback for existing targets that the name index did not protect; the links are + // looked up once per guard, not once per target. + let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect()); + for (p, what) in links { + if same_file(p, target) { return Err(format!( "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", target.display() From 3793e8fc3341dcf28ac29aefff8d93c93e8ebf43 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:12:49 -0400 Subject: [PATCH 22/30] =?UTF-8?q?LR-LIB-PEOPLE:=20clip=20XMP=20face=20regi?= =?UTF-8?q?ons=20to=20the=20photo,=20drop=20off-photo=20ones,=20read=20Rot?= =?UTF-8?q?ation=20=CF=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A region parsed from mwg-rs:Regions is now intersected with the 0..1 frame and dropped when it misses the photo, so a hostile or stale sidecar (x=1e5, a 1e5-wide box) can't park a box far outside the image. Lightroom's mwg-rs:Rotation of ±π (Exif orientation 3) is mapped to the upright frame like the quarter turns; mirrored orientations can't be expressed in MWG and are taken as written. Co-Authored-By: Claude Opus 5.5 --- crates/meta/src/xmp.rs | 68 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index f86876cae..b8a541096 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -414,16 +414,26 @@ fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option (cx / dw, cy / dh, w / dw, h / dh), (true, None) => return None, }; - // Lightroom Classic stores the box of a photo with Exif orientation 6 / 8 in the sensor's own frame - // and writes the orientation as the region's `mwg-rs:Rotation` (−π/2 for 6, +π/2 for 8; 0 for 1) - // while `AppliedToDimensions` names the upright size. Turn it into the upright frame. + // Lightroom Classic stores the box of a photo with Exif orientation 3 / 6 / 8 in the sensor's own + // frame and writes the orientation as the region's `mwg-rs:Rotation` (π for 3, −π/2 for 6, +π/2 for + // 8; 0 for 1) while `AppliedToDimensions` names the upright size. Turn it into the upright frame. + // MWG has no way to say "mirrored", so orientations 2/4/5/7 can't be told apart from their unmirrored + // twins here; boxes on mirrored photos are taken as written (see LR-LIB-PEOPLE in docs/parity.md). let rotation = item.field("mwg-rs:Rotation").and_then(XmpValue::text).and_then(parse_number).filter(|r| r.is_finite()); - let quarter = std::f64::consts::FRAC_PI_2; + let (quarter, half) = (std::f64::consts::FRAC_PI_2, std::f64::consts::PI); let (cx, cy, w, h) = match rotation { Some(r) if (r + quarter).abs() < 0.05 => (1.0 - cy, cx, h, w), Some(r) if (r - quarter).abs() < 0.05 => (cy, 1.0 - cx, h, w), + Some(r) if (r.abs() - half).abs() < 0.05 => (1.0 - cx, 1.0 - cy, w, h), _ => (cx, cy, w, h), }; + // Keep only the part of the box inside the photo: a region hanging off an edge (common for faces + // cut by the frame) is clipped, and one entirely outside it (hostile or stale) is dropped, so every + // stored rect is within 0..1 and non-empty. + let rect = Rect::from_center(Point::new(cx, cy), w, h).intersect(&Rect::UNIT); + if rect.is_empty() { + return None; + } let name = item.field("mwg-rs:Name").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty()); let description = item.field("mwg-rs:Description").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty()); let kind = match item.field("mwg-rs:Type").and_then(XmpValue::text).unwrap_or("") { @@ -433,7 +443,7 @@ fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option RegionKind::BarCode, t => RegionKind::Other(t.to_string()), }; - Some(Region { rect: Rect::from_center(Point::new(cx, cy), w, h), kind, name, description }) + Some(Region { rect, kind, name, description }) } fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String { @@ -883,8 +893,9 @@ mod tests { assert_eq!(regions[2].description.as_deref(), Some("Good boy")); } - /// Lightroom Classic writes a portrait photo's (Exif orientation 6 / 8) box in the sensor's frame and - /// records the orientation as `mwg-rs:Rotation` (−π/2 / +π/2); the reader returns the upright frame. + /// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame + /// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the + /// upright frame. #[test] fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() { let x = r#" @@ -903,16 +914,24 @@ mod tests { + + + + + + "#; let regions = parse_xmp(x).unwrap().metadata.regions; - assert_eq!(regions.len(), 3, "{regions:?}"); + assert_eq!(regions.len(), 5, "{regions:?}"); let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9); assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect); assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect); assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect); + assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect); + assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect); } /// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an @@ -975,6 +994,39 @@ mod tests { assert_eq!(regions[0].kind, RegionKind::Face); } + /// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a + /// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view + /// would then draw or crop far outside the image. + #[test] + fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() { + let x = r#" + + + + + Far right + Far left + Touching edge + Huge + Corner + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect(); + assert_eq!(names, ["Huge", "Corner"], "{regions:?}"); + for r in ®ions { + let q = r.rect; + assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}"); + } + assert_eq!(regions[0].rect, Rect::UNIT); + let c = regions[1].rect; + assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}"); + } + /// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). #[test] fn regions_merge_like_keywords() { From 313c555b5040aea97e3a67b4f5a522418ddaec98 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:13:44 -0400 Subject: [PATCH 23/30] LR-BEHAV-LOCALIZE: compile-checked tr_format! for every language catalog Restore main's compile-checked formatting, generalised to N catalogs: build.rs writes one tr_format! arm per English message that matches on the active language and calls format! with that language's translation (English is the fallback arm). The runtime substitution dropped format specs ({:.1}, {:+}, {:.0}%), e.g. the smart-preview size and the merge progress percentage. - formats catalogs use main's convention again ({0}/{1} for reordering, {:.0} to consume an unused plural suffix); zh-hans converted, with the size and percentage placeholders fixed by hand - build.rs: auto-discovers locales/*-formats.json, validates the code (^[a-z]+(-[a-z]+)*$), fails when the catalogs' keys differ, and reruns when the locales directory changes - zh-hans: translate the strings main added since the PR branched - test: precision, sign and reordered positional values survive in every language Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/build.rs | 147 +++++++++++--------- crates/ui-egui/locales/zh-hans-formats.json | 125 +++++++++-------- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/src/i18n.rs | 60 +++----- docs/localization-ja.md | 7 +- docs/localization-zh-hans.md | 12 +- docs/localization.md | 21 +-- 7 files changed, 183 insertions(+), 193 deletions(-) diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 600997fdd..ebdfa0478 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,85 +1,41 @@ //! Compile the translated format strings into the `tr_format!` macro. //! //! `locales/-formats.json` maps an English format string to its translation for one language. -//! Each catalog becomes a `translated_format` match arm, so every language's placeholders are -//! checked by Rust's `format!` — a wrong placeholder is a compile error, not a runtime `{}`. -//! Adding a language means adding its file; this script picks it up by name. -use std::{collections::BTreeMap, env, fs, path::PathBuf}; +//! Every English message becomes one `tr_format!` arm that picks the active language and formats +//! that language's translation with `format!` — so Rust checks every catalog's placeholders against +//! the call site's arguments (a wrong or forgotten placeholder is a compile error, and `{:.1}`, +//! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means +//! adding its file (and its `language_table!` entry); this script picks the file up by name. +//! +//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog +//! does not compile, so a message missing from one language would otherwise go unnoticed until it +//! shows English. +use std::{ + collections::{BTreeMap, BTreeSet}, + env, fs, + path::PathBuf, +}; fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=build.rs"); + // The directory itself, so a catalog added or removed reruns this script. + println!("cargo:rerun-if-changed=locales"); let out = PathBuf::from(env::var("OUT_DIR")?); fs::write(out.join("tr-formats.rs"), generate()?)?; Ok(()) } -fn generate() -> Result> { - let mut catalogs = Vec::new(); - for entry in fs::read_dir("locales")? { - let path = entry?.path(); - let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_string(); - // `en` is the source language: it has no catalog, its formats are the source themselves. - let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue }; - println!("cargo:rerun-if-changed=locales/{name}"); - let messages: BTreeMap = serde_json::from_str(&fs::read_to_string(&path)?)?; - let variant = code.split(['-', '_']).filter(|part| !part.is_empty()).map(capitalize).collect::(); - catalogs.push((variant, messages)); +/// `ja` → `Ja`, `zh-hans` → `ZhHans`: the `Locale` variant the `language_table!` entry declares. +/// The code must be a lowercase BCP-47-like tag (`^[a-z]+(-[a-z]+)*$`), so the variant is always a +/// valid Rust identifier. +fn variant_for(code: &str) -> Result { + let valid = !code.is_empty() && code.split('-').all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_lowercase())); + if !valid { + return Err(format!("locales/{code}-formats.json: the language code must match ^[a-z]+(-[a-z]+)*$")); } - catalogs.sort_by(|a, b| a.0.cmp(&b.0)); - - // `tr_format!` formats the English source at the call site (where `format!` can check it) and - // also names every value, which is what lets a translation place them. Positional values are - // addressed as `_1`, `_2`, … in call-site order; `macro_rules!` cannot count, so the slot - // numbering is spelled out here and grows with `POSITIONAL_SLOTS` if a call site ever needs more. - const POSITIONAL_SLOTS: usize = 12; - let mut macro_source = String::from(concat!( - "/// Translate a format string with runtime values, keeping the English source's placeholders\n", - "/// checked by `format!` at the call site. `build.rs` writes this macro from `locales/`.\n", - "macro_rules! tr_format {\n", - " ($format:literal $(, $($args:tt)*)?) => {\n", - " $crate::i18n::tr_format!(@scan () () (_positional) ; $format; $($($args)*)?)\n", - " };\n", - " (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal; $name:ident = $value:expr $(, $($rest:tt)*)?) => {\n", - " $crate::i18n::tr_format!(@scan ($($raw)* $name = $value,) ($($values)* (stringify!($name), format!(\"{}\", $value)),)\n", - " $position ; $format; $($($rest)*)?)\n", - " };\n", - )); - for slot in 1..=POSITIONAL_SLOTS { - macro_source.push_str(&format!( - " (@scan ($($raw:tt)*) ($($values:tt)*) (_positional) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{slot}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n (@scan ($($raw:tt)*) ($($values:tt)*) (_{slot}) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{next}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n", - next = slot + 1 - )); - } - macro_source.push_str(concat!( - " (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal;) => {\n", - " $crate::i18n::format_message($format, format!($format, $($raw)*), &[$($values)*])\n", - " };\n", - "}\n", - "\n", - "// `macro_rules!` is textual: the module re-export is what lets call sites keep saying\n", - "// `crate::i18n::tr_format!`.\n", - "pub(crate) use tr_format;\n", - "\n", - "impl Locale {\n", - " /// The translated format string for `source`, or `None` when this language has none.\n", - " ///\n", - " /// Generated by `build.rs` from `locales/-formats.json`.\n", - " pub fn translated_format(self, source: &str) -> Option<&'static str> {\n", - " match self {\n", - )); - let mut source = macro_source; - for (variant, messages) in &catalogs { - source.push_str(&format!(" Locale::{variant} => match source {{\n")); - for (english, translated) in messages { - source.push_str(&format!(" {} => Some({}),\n", serde_json::to_string(english)?, serde_json::to_string(translated)?)); - } - source.push_str(" _ => None,\n },\n"); - } - source.push_str(" _ => None,\n }\n }\n}\n"); - Ok(source) + Ok(code.split('-').map(capitalize).collect()) } -/// `zh-hans` → `ZhHans`, the enum variant the `language_table!` entry declares. fn capitalize(part: &str) -> String { let mut chars = part.chars(); match chars.next() { @@ -87,3 +43,58 @@ fn capitalize(part: &str) -> String { None => String::new(), } } + +fn generate() -> Result> { + let mut catalogs: Vec<(String, String, BTreeMap)> = Vec::new(); + for entry in fs::read_dir("locales")? { + let path = entry?.path(); + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_string(); + // `en` is the source language: it has no catalog, its formats are the source themselves. + let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue }; + println!("cargo:rerun-if-changed=locales/{name}"); + let variant = variant_for(code)?; + let messages: BTreeMap = + serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?; + catalogs.push((code.to_string(), variant, messages)); + } + catalogs.sort_by(|a, b| a.0.cmp(&b.0)); + + // Every catalog translates the same messages. + let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect(); + let mut problems = Vec::new(); + for (code, _, messages) in &catalogs { + for key in &all { + if !messages.contains_key(*key) { + problems.push(format!("locales/{code}-formats.json is missing {key:?}")); + } + } + } + if !problems.is_empty() { + return Err(problems.join("\n").into()); + } + + let mut source = String::from(concat!( + "/// Translate a format string, checked by `format!` in every language.\n", + "/// `build.rs` writes one arm per message from `locales/*-formats.json`.\n", + "macro_rules! tr_format {\n", + )); + for english in all { + let en = serde_json::to_string(english)?; + source.push_str(&format!(" ({en} $(, $($args:tt)*)?) => {{\n match $crate::i18n::language() {{\n")); + for (_, variant, messages) in &catalogs { + if let Some(translated) = messages.get(english) { + let translated = serde_json::to_string(translated)?; + source.push_str(&format!(" $crate::i18n::Locale::{variant} => format!({translated} $(, $($args)*)?),\n")); + } + } + source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n")); + } + source.push_str(concat!( + "}\n", + "\n", + "// `macro_rules!` is textual: the module re-export is what lets call sites say\n", + "// `crate::i18n::tr_format!`.\n", + "pub(crate) use tr_format;\n", + )); + Ok(source) +} diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index a1e9f86c2..c30f92ea0 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -1,19 +1,19 @@ { " · {skipped} skipped (file exists)": " · 已跳过 {skipped} 个(文件已存在)", - " · {} duplicate{} skipped": " · 已跳过 {_1} 个重复项", - " · {} failed: {}": " · {_1} 个失败:{_2}", - " · {} moved": " · 已移动 {_1} 个", - " · {} not readable": " · {_1} 个无法读取", - ", {failed} file{} not readable": ",{failed} 个文件无法读取", + " · {} duplicate{} skipped": " · 已跳过 {0} 个重复项{1:.0}", + " · {} failed: {}": " · {0} 个失败:{1}", + " · {} moved": " · 已移动 {0} 个", + " · {} not readable": " · {0} 个无法读取", + ", {failed} file{} not readable": ",{failed} 个文件无法读取{0:.0}", "Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片", - "Add {} Photo{}": "添加 {_1} 张照片", - "Add “{}” to Favorites": "将“{_1}”添加到收藏夹", - "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片", - "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{_2}”", + "Add {} Photo{}": "添加 {0} 张照片{1:.0}", + "Add “{}” to Favorites": "将“{0}”添加到收藏夹", + "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}", + "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}", "All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。", "Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片", - "Automatic ({} MB)": "自动({_1} MB)", - "Click {}:1": "单击 {_1}:1", + "Automatic ({} MB)": "自动({0} MB)", + "Click {}:1": "单击 {0}:1", "Create {n} Virtual Copies": "创建 {n} 个虚拟副本", "Decoder: {reason}": "解码器:{reason}", "Delete \"{label}\"": "删除 \"{label}\"", @@ -22,83 +22,86 @@ "Delete “{current}”": "删除“{current}”", "Duplicate \"{label}\"": "复制 \"{label}\"", "Example: {example}": "示例:{example}", - "Exported {n} photo{}": "已导出 {n} 张照片", - "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片", - "Exported {} point curve preset{}": "已导出 {_1} 个点曲线预设", - "Exported {} preset{}": "已导出 {_1} 个预设", - "Exporting {} of {total}": "正在导出 {total} 张中的第 {_1} 张", - "Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选", - "Healed {n} dust spot{}": "已修复 {n} 处尘点", - "Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设", - "Imported {n} preset{}": "已导入 {n} 个预设", - "Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取", - "Imported {} photo{}": "已导入 {_1} 张照片", - "Matches: {}": "匹配项:{_1}", - "Merging… {stage} {:.0}%": "合成中… {stage} %", + "Exported {n} photo{}": "已导出 {n} 张照片{0:.0}", + "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片{0:.0}", + "Exported {} point curve preset{}": "已导出 {0} 个点曲线预设{1:.0}", + "Exported {} preset{}": "已导出 {0} 个预设{1:.0}", + "Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张", + "Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选{0:.0}", + "Healed {n} dust spot{}": "已修复 {n} 处尘点{0:.0}", + "Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设{0:.0}", + "Imported {n} preset{}": "已导入 {n} 个预设{0:.0}", + "Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取{0:.0}{1:.0}", + "Imported {} photo{}": "已导入 {0} 张照片{1:.0}", + "Matches: {}": "匹配项:{0}", + "Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%", "Move {what} to Recently Deleted?": "将 {what} 移到最近删除?", "Named ({named_n})": "已命名({named_n})", - "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置", + "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}", "Preset: {name}": "预设:{name}", - "Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({_1})", - "Preview only — {}": "仅预览 — {_1}", - "Profile: {}": "配置文件:{_1}", - "Proof Copy ({})": "校样副本({_1})", - "Proof Preview · {}": "校样预览 · {_1}", + "Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({0})", + "Preview only — {}": "仅预览 — {0}", + "Profile: {}": "配置文件:{0}", + "Proof Copy ({})": "校样副本({0})", + "Proof Preview · {}": "校样预览 · {0}", "Quick Develop · {n} photos": "快速编辑 · {n} 张照片", - "Rated {}": "已评级 {_1}", + "Rated {}": "已评级 {0}", "Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张", - "Redo {}": "重做:{_1}", - "Remove “{}” from Favorites": "从收藏夹中移除“{_1}”", + "Redo {}": "重做:{0}", + "Remove “{}” from Favorites": "从收藏夹中移除“{0}”", "Rename {n} Photos…": "重命名 {n} 张照片…", "Rename “{name}”": "重命名“{name}”", "Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}", - "Reset {} Channel": "重置 {_1} 通道", - "Saved export preset “{}”": "已保存导出预设“{_1}”", - "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置", - "Showing {n} of {}": "显示 {_1} 张中的 {n} 张", - "Smart album: {}": "智能相册:{_1}", - "Undo {}": "撤销 {_1}", - "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {_1} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。", - "Version: {}": "版本:{_1}", + "Reset {} Channel": "重置 {0} 通道", + "Saved export preset “{}”": "已保存导出预设“{0}”", + "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}", + "Showing {n} of {}": "显示 {0} 张中的 {n} 张", + "Smart album: {}": "智能相册:{0}", + "Undo {}": "撤销 {0}", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {0} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。", + "Version: {}": "版本:{0}", "none (CPU rendering): {why}": "无(CPU 渲染):{why}", "the {n} selected photos": "选中的 {n} 张照片", - "the {} photos in view": "视图中的 {_1} 张照片", + "the {} photos in view": "视图中的 {0} 张照片", "these {count} photos": "这 {count} 张照片", - "{count} smart preview{} · {:.1} MB": "{count} 个智能预览 · {_1} MB", + "{count} smart preview{} · {:.1} MB": "{count} 个智能预览{0:.0} · {1:.1} MB", "{f} folders (and their subfolders)": "{f} 个文件夹(及其子文件夹)", "{label} Grading": "{label} 颜色分级", "{label} {d:+} on every selected photo": "每张选中照片的 {label} {d:+}", "{label}, {n} photos": "{label},{n} 张照片", "{matching} of {t} photos": "{t} 张照片中的 {matching} 张", "{matching} photos": "{matching} 张照片", - "{mode} spot {} of {}": "{mode}:第 {_1} 个污点,共 {_2} 个", + "{mode} spot {} of {}": "{mode}:第 {0} 个污点,共 {1} 个", "{msg} ({failed} failed)": "{msg}({failed} 项失败)", "{name} Label": "{name} 标签", "{name} Off": "已关闭 {name}", "{name} On": "已开启 {name}", "{name} — double-click to reset it": "{name} — 双击可重置", - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 项更改已保存在内存中,但尚未写入磁盘:{e}\nLightCraft 会自动重试;现在退出将丢失这些更改。", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 项更改已保存在内存中,但尚未写入磁盘:{e}\nLightCraft 会自动重试;现在退出将丢失这些更改。{0:.0}{1:.0}", "{n} found · {sel} selected": "找到 {n} 张 · 已选 {sel} 张", "{n} photos": "{n} 张照片", - "{n} photos in {} are hidden by the filters above": "{_1} 中的 {n} 张照片被上方的过滤器隐藏", - "{n} photo{}": "{n} 张照片", - "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新", - "{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新", + "{n} photos in {} are hidden by the filters above": "{0} 中的 {n} 张照片被上方的过滤器隐藏", + "{n} photo{}": "{n} 张照片{0:.0}", + "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新{0:.0}", + "{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新{0:.0}", "{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}", - "{stage}… {:.0}%": "{stage}… %", + "{stage}… {:.0}%": "{stage}… {0:.0}%", "{what} merge added": "已添加 {what} 合并", "{what} merge failed: {e}": "{what} 合并失败:{e}", - "{} and higher": "{_1} 及以上", - "{} files": "{_1} 个文件", - "{} files and {f} folder{}": "{_1} 个文件和 {f} 个文件夹", - "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {_1} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。", - "{} photos": "{_1} 张照片", - "{} photo{} not readable": "{_1} 张照片无法读取", - "{} spot(s) on this photo": "此照片上有 {_1} 处污点", + "{} and higher": "{0} 及以上", + "{} files": "{0} 个文件", + "{} files and {f} folder{}": "{0} 个文件和 {f} 个文件夹{1:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {0} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。", + "{} photos": "{0} 张照片", + "{} photo{} not readable": "{0} 张照片无法读取{1:.0}", + "{} spot(s) on this photo": "此照片上有 {0} 处污点", "· {dups} already in the library": "· {dups} 张已在图库中", - "· {n} photo{}": "· {n} 张照片", + "· {n} photo{}": "· {n} 张照片{0:.0}", "… and {more} more": "…以及另外 {more} 项", - "Import cancelled · {} photo{} added": "导入已取消 · 已添加 {_1} 张照片", - "Reading photos… {} of {}": "正在读取照片… 第 {_1} 张,共 {_2} 张", - "Adding photos… {} of {}": "正在添加照片… 第 {_1} 张,共 {_2} 张" + "Import cancelled · {} photo{} added": "导入已取消 · 已添加 {0} 张照片{1:.0}", + "Reading photos… {} of {}": "正在读取照片… 第 {0} 张,共 {1} 张", + "Adding photos… {} of {}": "正在添加照片… 第 {0} 张,共 {1} 张", + " ({} in Recently Deleted)": "(其中 {0} 张在“最近删除”中)", + "Already in the library: {} photo{} selected in All Photos": "已在图库中:已在“所有照片”中选择 {0} 张照片{1:.0}", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 3ebd08d20..5cac9dfef 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1017,5 +1017,7 @@ "Quit": "退出", "Temporary session — your library isn't open, and nothing you do here is saved.": "临时会话 — 图库未打开,此处所做的任何操作都不会保存。", "Back Up Library…": "备份图库…", - "Restore Library from Backup…": "从备份恢复图库…" + "Restore Library from Backup…": "从备份恢复图库…", + "In Recently Deleted": "在“最近删除”中", + "Reload from Disk": "从磁盘重新读取" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 55f52d523..92ac47ee0 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -185,44 +185,8 @@ fn catalog_verbatim(language: Locale) -> BTreeMap { language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() } -/// Whether the language translates anything at all (the `tr_format!` fast path). -pub fn has_translations(language: Locale) -> bool { - !language.catalog().is_empty() -} - -/// Translate a format string with runtime values, as `tr_format!` calls it. -/// -/// `english` is the source text already formatted with the call site's values, and `values` names -/// those values so the translation can place them. A message with no translation, or a translation -/// without placeholders, keeps `english` as it is. -pub fn format_message(source: &str, english: String, values: &[(&str, String)]) -> String { - let Some(translated) = language().translated_format(source) else { - return english; - }; - if !translated.contains('{') { - return translated.to_string(); - } - let mut out = String::with_capacity(translated.len()); - let mut rest = translated; - while let Some(start) = rest.find('{') { - let Some(end) = rest[start..].find('}') else { break }; - let field = &rest[start + 1..start + end]; - let (name, _) = field.split_once(':').unwrap_or((field, "")); - out.push_str(&rest[..start]); - // The catalogs spell every value out by name; anything else (a `{}` that only the English - // text used, or a name no call site passes) is left exactly as the translator wrote it. - match values.iter().find(|(key, _)| *key == name) { - Some((_, value)) => out.push_str(value), - None => out.push_str(&rest[start..start + end + 1]), - } - rest = &rest[start + end + 1..]; - } - out.push_str(rest); - out -} - -// `tr_format!` (one arm per language) and the translated-format lookup are generated by -// `build.rs`; `Locale` is declared above, so the generated `impl Locale` has it in scope. +// `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from +// `locales/*-formats.json`. include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); #[cfg(test)] @@ -311,8 +275,7 @@ mod tests { /// falls back to the English format rather than dropping the values. #[test] fn formats_render_in_every_language() { - // The English source is what `format!` checks at the call site; each translation then places - // the same values by name (`{n}`, `{total}`) or by position (`{_1}`, `{_2}`). + // Every language's format string is checked by `format!` against the call site's values. set_language(Locale::En); assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); @@ -322,10 +285,21 @@ mod tests { set_language(Locale::Ja); assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました"); assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました"); + // Format specs survive translation: precision, sign, and values a language reorders. + for language in Locale::ALL { + set_language(*language); + let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); + assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); + let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); + assert!(merging.contains("42%"), "{language:?}: {merging}"); + let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); + assert!(delta.contains("+5"), "{language:?}: {delta}"); + let reading = tr_format!("Reading photos… {} of {}", 3, 10); + let (three, ten) = (reading.find('3'), reading.find("10")); + assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); + } set_language(Locale::En); - // A format no catalog carries keeps its values, and one with no values keeps its text. - assert_eq!(tr_format!("{n} uncatalogued {thing}", n = 3, thing = "things"), "3 uncatalogued things"); - assert_eq!(tr_format!("Activity"), "Activity"); + assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); } /// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation diff --git a/docs/localization-ja.md b/docs/localization-ja.md index 9dfa998e8..bcfb8a8dd 100644 --- a/docs/localization-ja.md +++ b/docs/localization-ja.md @@ -19,10 +19,9 @@ LightCraft は日本語・簡体字中国語・英語(English)の表示に 静的な表示文言は `crates/ui-egui/locales/ja.json`、可変値を含む表示文言は `crates/ui-egui/locales/ja-formats.json` にあります。英文をキーにして翻訳を管理します。 -可変文言は、呼び出し側の英語の書式を `format!` がそのまま検査し、その値を翻訳側が -名前(`{n}`、`{total}`)または位置(`_1`、`_2` …)で参照します。英語の単複数語尾のように -中国語・日本語で不要な値は、**単に参照しないだけ**です(`{:.0}` のような空の引数は不要)。 -両言語の訳文はビルド時に `format!` の検査を受けるため、存在しない名前を書くとコンパイルエラーになります。 +可変文言の訳文はすべてビルド時にRustの `format!` 検査を受けます。値は名前(`{n}`)または +位置(`{0}`、`{1}`)で参照し、`{:.1}` などの書式指定は英文と同じものを使います。 +英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。 `LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。 表示・フォント・言語の切り替え・設定の保存・コマンドIDの保持は diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index f643c2aa8..2da811bb4 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -22,13 +22,13 @@ LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 含数值的文字遵循以下约定: -- 调用点的英文原文由 `format!` 检查(占位符写错会编译失败),翻译再按名字或位置引用同一个值。 +- 每个译文都是普通的 Rust 格式字符串,构建时由 `format!` 对照调用点的参数检查(占位符写错会编译失败)。 - **按名字**:`{n}`、`{label}`、`{total}` 等与英文原文中的名字一致。 -- **按位置**:`_1`、`_2`、`_3` … 对应调用点参数的先后顺序(从 1 开始),用于调整语序, - 例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{_2}”`。 -- 英文用来表示单复数的那个参数(`"s"` / `""`)在中文里**不需要写**,直接不引用即可; - 不需要 `{:.0}` 之类的空占位符。 -- 译文里的占位符同样会被 `format!` 检查:写了不存在的名字就编译不过。 +- **按位置**:`{0}`、`{1}` … 对应调用点无名参数的先后顺序(从 0 开始),用于调整语序, + 例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{1}”{0:.0}`。 +- 保留英文原文的格式说明(`{:.1}`、`{d:+}`、`{:.0}%`)。 +- `format!` 不允许未使用的参数:英文用来表示单复数的那个参数(`"s"` / `""`)在中文里写成 + `{:.0}`(精度为 0 的字符串不输出任何内容)。 `LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。 diff --git a/docs/localization.md b/docs/localization.md index da3ff77df..b846df7a8 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -38,21 +38,22 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md) and [`localization-j text unchanged when there is no entry; `tr_format!` handles text with runtime values. Both are presentation-only: command ids, file names and user-entered metadata are never translated. -`tr_format!` is generated by `crates/ui-egui/build.rs`. It formats the **English source at the call -site**, which is what gives `format!` a literal to check against that call site's own arguments, and -it also names those values so a translation can place them: +`tr_format!` is generated by `crates/ui-egui/build.rs`: one arm per English message, which formats +the active language's translation with `format!` (English is the fallback arm). Every catalog's +format string is therefore checked by Rust against the call site's own arguments: ```rust tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album, n = n) ``` -- A translation refers to a value **by name** exactly as the English source spells it (`{n}`), or - **by position** as `_1`, `_2`, … in call-site order. Reordering is fine: - `"已将 {n} 张照片添加到“{_2}”"`. -- The English plural suffix (`"s"` / `""`) is simply not referenced in languages without plurals. -- Every translation is placeholder-checked at compile time: an unknown name, or a spec that does not - match its value, fails the build rather than printing `{}` at runtime. -- A source string with no translation falls back to the English text, values included. +- A translation is an ordinary Rust format string over the same arguments. Refer to named values by + name (`{n}`) and to positional ones by index when the word order changes (`{0}`, `{1}`): + `"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`). +- `format!` rejects an unused argument, so a value a language does not need — the English plural + suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing). +- Every formats catalog carries the same messages (`build.rs` fails the build otherwise), and a + `tr_format!` call whose message is in no catalog does not compile: add it to every + `*-formats.json` file. ## Testing and looking at it From 60a864977987fae7734826203c566273ab37d48d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:16:02 -0400 Subject: [PATCH 24/30] LR-LIB-PEOPLE: leave empty face regions out of the catalog JSON Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/model.rs | 6 ++++-- crates/catalog/src/tests.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index ec8d28d17..9248b2206 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -183,8 +183,10 @@ pub struct Meta { pub copyright_url: String, pub creator: String, pub keywords: Vec, - /// Face/pet/focus regions read from XMP (MWG-RS). Read-only today; nothing in - /// LightCraft writes regions yet. + /// Face/pet/focus regions read from XMP (MWG-RS), on the upright (EXIF-oriented) photo. + /// Removing or resizing one edits the catalog only; LightCraft never writes regions to XMP. + /// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged. + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub regions: Vec, } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 7897f7972..6f76b694e 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -360,3 +360,23 @@ fn people_from_named_face_regions() { ); assert_eq!(names(Filter { rating: 5, ..Default::default() }), vec![], "nobody in the filtered photos"); } + +/// Most photos have no regions: the field is left out of the catalog JSON then, and a catalog written +/// before regions existed (no `regions` key) reads with none. +#[test] +fn empty_regions_are_not_serialized_and_default_when_missing() { + let m = Meta { keywords: vec!["k".into()], ..Default::default() }; + let v = serde_json::to_value(&m).unwrap(); + assert!(v.get("regions").is_none(), "{v}"); + let back: Meta = serde_json::from_value(v).unwrap(); + assert!(back.regions.is_empty()); + let mut with = m.clone(); + with.regions.push(lightcraft_meta::Region { + rect: lightcraft_meta::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some("A".into()), + description: None, + }); + let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap(); + assert_eq!(back, with); +} From 87a58b4183839ebcc4874314027b534a5841f2bc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:17:06 -0400 Subject: [PATCH 25/30] LR-BEHAV-LOCALIZE: one Language menu entry per language; parity reads the language table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UI_COMMANDS still listed app.language.english/japanese next to the new LANGUAGE_COMMANDS table, so Edit > Language showed English and 日本語 twice - LANGUAGE_COMMANDS is `pub const`, the form xtask parity scans for; a test reads the real menus.rs and checks every language id is found once - the Simplified Chinese command is app.language.simplifiedChinese, in line with the other camelCase command ids (the settings code stays zh-hans) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n.rs | 2 +- crates/ui-egui/src/menus.rs | 8 +++----- docs/localization.md | 2 +- docs/parity.md | 2 +- xtask/src/parity.rs | 13 +++++++++++++ 5 files changed, 19 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 92ac47ee0..5a7e0f304 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -366,7 +366,7 @@ mod tests { /// The language menu covers every language, and a language's own command selects it. #[test] fn language_commands_cover_every_language() { - let commands = [("app.language.english", Locale::En), ("app.language.zh-hans", Locale::ZhHans), ("app.language.japanese", Locale::Ja)]; + let commands = [("app.language.english", Locale::En), ("app.language.simplifiedChinese", Locale::ZhHans), ("app.language.japanese", Locale::Ja)]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); for (id, language) in commands { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92910324d..9f63e1088 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -12,9 +12,9 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static /// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language /// added to the table shows up in the menu (and in the control channel's command list) by itself. -const LANGUAGE_COMMANDS: &[UiCommand] = &[ +pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), - ("app.language.zh-hans", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), + ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), ]; @@ -29,15 +29,13 @@ pub fn ui_commands() -> impl Iterator { pub fn language_from_command(id: &str) -> Option { match id { "app.language.english" => Some(crate::i18n::Locale::En), - "app.language.zh-hans" => Some(crate::i18n::Locale::ZhHans), + "app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans), "app.language.japanese" => Some(crate::i18n::Locale::Ja), _ => None, } } pub const UI_COMMANDS: &[UiCommand] = &[ - ("app.language.english", "English", None, "Edit>Language"), - ("app.language.japanese", "日本語", None, "Edit>Language"), ("view.photoGrid", "Photo Grid", None, "View"), ("view.squareGrid", "Square Grid", None, "View"), // G: Photo Grid ↔ Square Grid (from other views: the photo grid) diff --git a/docs/localization.md b/docs/localization.md index b846df7a8..3eb5efcaa 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -70,7 +70,7 @@ LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot --demo --script tour.jsonl - ``` or, with the app running (`--control 7980`), run the language command -(`engine.execute {"command": "app.language.zh-hans"}`) and take a screenshot. +(`engine.execute {"command": "app.language.simplifiedChinese"}`) and take a screenshot. ## Language codes and settings diff --git a/docs/parity.md b/docs/parity.md index d81f60f90..fc9226782 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -503,7 +503,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | -| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.zh-hans`, `cmd:app.language.japanese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | +| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.japanese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | | LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese and Japanese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Technical errors/release notes and date grouping headers remain English, and the web build embeds only the Japanese face. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 3e59a2013..321e3413c 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -442,5 +442,18 @@ old fn reads_ui_command_ids() { let src = "pub const UI_COMMANDS: &[UiCommand] = &[\n (\"view.detail\", \"Detail\", Some(\"D\"), \"View\"),\n (\"app.about\", \"About\", None, \"\"),\n];\nfn x() { (\"not.this\", 1); }"; assert_eq!(ui_command_ids(src), ["view.detail", "app.about"]); + let languages = "pub const LANGUAGE_COMMANDS: &[UiCommand] = &[\n (\"app.language.english\", Locale::En.name(), None, \"Edit>Language\"),\n];\n"; + assert_eq!(ui_command_ids(&format!("{languages}{src}")), ["view.detail", "app.about", "app.language.english"]); + } + + /// The real menu tables: both are found, so every language command is a known id. + #[test] + fn reads_the_language_commands_from_menus_rs() { + let menus = include_str!("../../crates/ui-egui/src/menus.rs"); + let ids = ui_command_ids(menus); + for id in ["view.detail", "app.language.english", "app.language.japanese", "app.language.simplifiedChinese"] { + assert!(ids.iter().any(|known| known == id), "{id} not found"); + } + assert_eq!(ids.iter().filter(|id| id.as_str() == "app.language.english").count(), 1, "one Language menu entry per language"); } } From 8c253b28ada12368219aa4765cbc00b9f99dfc61 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:19:45 -0400 Subject: [PATCH 26/30] LR-LIB-PEOPLE: a sidecar that states mwg-rs:Regions replaces the photo's regions, even when empty Re-reading a sidecar whose region list was emptied by a region-aware app now clears the stale regions. A sidecar without mwg-rs:Regions at all (an app that doesn't do regions) says nothing about them and the photo's regions are kept. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sidecar.rs | 7 +++++- crates/engine/src/tests_xmp.rs | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index f57ac16a5..7e9249489 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -134,7 +134,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result + +
"#, + ) + .unwrap(); + s.execute("photo.readMetadataFromFile", &json!({})).unwrap(); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.rating, 3, "the sidecar was read"); + assert_eq!(p.meta.regions.len(), 1, "a sidecar without mwg-rs:Regions keeps the photo's regions"); + // a region-aware app removed every region: the sidecar says so, and the catalog follows + let emptied = MWG_REGIONS.replace( + r#" + + + + "#, + "", + ); + assert_ne!(emptied, MWG_REGIONS); + std::fs::write(src.join("portrait.xmp"), emptied).unwrap(); + s.execute("photo.readMetadataFromFile", &json!({})).unwrap(); + assert!(s.catalog.photo(id).unwrap().meta.regions.is_empty(), "an emptied region list clears stale regions"); + // undo brings them back + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1); + let _ = std::fs::remove_dir_all(&src); +} + fn synthetic_dng(xmp: &str) -> Vec { synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default()) } From 269934caca4e8a81200341e1512d963d1f4c2e21 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:19:50 -0400 Subject: [PATCH 27/30] LR-BEHAV-LOCALIZE: reinstall fonts on a language switch; restore the painted-UI tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the CJK fallback order follows the UI language's script, but the fonts were only installed on the first frame: switching 日本語 ↔ 简体中文 at runtime kept the previous language's Han forms. The app remembers the language its fonts were built for and reinstalls them when it changes. - the native menu bar rebuilds on any language change (keyed on the language code, not on "uses CJK fonts", which is true for both CJK languages) - restore the tests the language-table change dropped, for every language: core catalog entries, user-named menu items stay untranslated, the window paints each language and menu ids don't change with it, CJK glyphs in every UI family, and the UI runs without craft-fonts Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/native_menu.rs | 2 +- crates/ui-egui/src/i18n.rs | 156 +++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 10 ++ xtask/src/parity.rs | 3 +- 4 files changed, 162 insertions(+), 9 deletions(-) diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 33b5c8676..d3c9bdb7e 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec)]) -> String { out.push(';'); } } - let mut s = format!("{};", lightcraft_ui_egui::i18n::uses_cjk_fonts()); + let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code()); for (t, items) in bar { s.push_str(t); walk(items, &mut s); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5a7e0f304..c208ecb6e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -153,12 +153,6 @@ pub fn language() -> Locale { LOCALE.with(std::cell::Cell::get) } -/// Whether the active language needs the craft-fonts CJK faces (Inter alone has no CJK glyphs): -/// the native menu bar uses it to pick a text style. -pub fn uses_cjk_fonts() -> bool { - language().script() != "Latn" -} - /// Translate a built-in display label, preserving unknown labels verbatim. /// Never call this on editable user text, filenames or command identifiers. pub fn tr(source: &str) -> &str { @@ -366,7 +360,8 @@ mod tests { /// The language menu covers every language, and a language's own command selects it. #[test] fn language_commands_cover_every_language() { - let commands = [("app.language.english", Locale::En), ("app.language.simplifiedChinese", Locale::ZhHans), ("app.language.japanese", Locale::Ja)]; + let commands = + [("app.language.english", Locale::En), ("app.language.simplifiedChinese", Locale::ZhHans), ("app.language.japanese", Locale::Ja)]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); for (id, language) in commands { @@ -389,4 +384,151 @@ mod tests { let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); assert_eq!(restored.language, Locale::ZhHans); } + + #[test] + fn catalogs_contain_core_workflows() { + for language in Locale::ALL.iter().filter(|language| **language != Locale::En) { + for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] { + let value = language.tr(key); + assert!(!value.is_empty() && value != key, "{language:?}: {key}"); + } + } + } + + /// User-named menu items (presets, albums, label sets) are never translated; built-in labels are. + #[test] + fn menu_labels_translate_but_user_names_survive() { + for language in Locale::ALL { + set_language(*language); + assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color"); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color")); + } + set_language(Locale::ZhHans); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "颜色"); + set_language(Locale::Ja); + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー"); + set_language(Locale::En); + } + + /// The text the whole window paints over a few frames in `language`. + fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String { + fn collect(shape: &egui::epaint::Shape, text: &mut String) { + match shape { + egui::epaint::Shape::Text(shape) => { + text.push_str(&shape.galley.job.text); + text.push('\n'); + } + egui::epaint::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, text)), + _ => {} + } + } + app.ui.language = language; + let mut text = String::new(); + for frame in 0..4 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + // This inspects shapes without a renderer; discard texture uploads explicitly. + out.textures_delta.clear(); + text.clear(); + for shape in out.shapes { + collect(&shape.shape, &mut text); + } + } + text + } + + /// Every language is painted by the real window, switching at runtime reinstalls the fonts + /// (their CJK fallback order follows the language), and command ids never change with it. + #[test] + fn every_language_is_painted_and_menu_ids_stay_the_same() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); + let english_ids = ids(&app); + for language in Locale::ALL { + let text = painted_text(&ctx, &mut app, *language); + for label in ["My Photos", "All Photos"] { + assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label)); + } + assert_eq!(app.font_language, *language, "the fonts follow the language"); + assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent"); + } + let text = painted_text(&ctx, &mut app, Locale::Ja); + assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}"); + let text = painted_text(&ctx, &mut app, Locale::ZhHans); + assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}"); + set_language(Locale::En); + } + + /// The UI families, after one frame so the font definitions are loaded. + fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context { + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions(craft)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + ctx + } + + /// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI + /// family, Monospace included. + #[test] + fn craft_fonts_render_cjk_in_the_ui() { + let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字")]; + for (language, sample) in samples { + if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.covers(language.script())) { + eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + continue; + } + set_language(language); + let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS); + ctx.fonts_mut(|fonts| { + for family in + [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace] + { + let font = egui::FontId::new(13.0, family); + for ch in sample.chars() { + assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}"); + } + let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE); + let wide = 13.0 * (sample.chars().count() as f32 - 1.0); + assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size()); + } + }); + } + set_language(Locale::En); + } + + /// Built without craft-fonts, the UI (in every language) still installs its fonts and runs; + /// Latin text keeps Inter. + #[test] + fn the_ui_works_without_craft_fonts() { + let ctx = fonts_ctx(&[]); + ctx.fonts_mut(|fonts| { + // (Not Monospace: egui's `has_glyph` reports false for glyphs of the family's + // replacement-glyph face, which there is Hack, the face that draws Latin.) + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + assert!("LightCraft".chars().all(|ch| fonts.has_glyph(&font, ch)), "{font:?}"); + } + }); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + for language in Locale::ALL { + app.ui.language = *language; + for frame in 0..3 { + let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + } + set_language(Locale::En); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 90b72967d..5fb0bd7b0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -157,6 +157,9 @@ pub struct LightcraftApp { /// Clear `synthetic_mods` on the next frame. synthetic_mods_release: bool, styled: bool, + /// The language the installed fonts were built for: the CJK fallback order follows the UI + /// language's script, so switching language reinstalls them. + font_language: i18n::Locale, fonts_ready: bool, last_time: f64, /// Rect of the photo canvas and the displayed image (screen points) from the last frame. @@ -223,6 +226,7 @@ impl LightcraftApp { synthetic_mods: egui::Modifiers::NONE, synthetic_mods_release: false, styled: false, + font_language: i18n::Locale::En, fonts_ready: false, last_time: 0.0, canvas_rect: None, @@ -502,6 +506,12 @@ impl LightcraftApp { let repaint = ctx.clone(); self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint())); self.styled = true; + self.font_language = self.ui.language; + } else if self.font_language != self.ui.language { + // Shared Han characters take the active language's forms (Japanese faces for 日本語, + // the Simplified Chinese face for 简体中文): rebuild the fallback order. + theme::install_fonts(ctx); + self.font_language = self.ui.language; } else { self.fonts_ready = true; } diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 321e3413c..fc87ecc38 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -442,7 +442,8 @@ old fn reads_ui_command_ids() { let src = "pub const UI_COMMANDS: &[UiCommand] = &[\n (\"view.detail\", \"Detail\", Some(\"D\"), \"View\"),\n (\"app.about\", \"About\", None, \"\"),\n];\nfn x() { (\"not.this\", 1); }"; assert_eq!(ui_command_ids(src), ["view.detail", "app.about"]); - let languages = "pub const LANGUAGE_COMMANDS: &[UiCommand] = &[\n (\"app.language.english\", Locale::En.name(), None, \"Edit>Language\"),\n];\n"; + let languages = + "pub const LANGUAGE_COMMANDS: &[UiCommand] = &[\n (\"app.language.english\", Locale::En.name(), None, \"Edit>Language\"),\n];\n"; assert_eq!(ui_command_ids(&format!("{languages}{src}")), ["view.detail", "app.about", "app.language.english"]); } From 4ded49ecd9b2b2a43f75d61adb9401a3fe92782d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:21:04 -0400 Subject: [PATCH 28/30] LR-LIB-PEOPLE: face boxes and People close-ups follow Rotate Left/Right Regions are stored on the upright (EXIF-oriented) photo, but the loupe's normalized frame and the close-up crop include the user's orientation. The loupe overlay now maps each box into that frame (and a resize drag back out of it), and face_job maps the face and swaps the photo's size for a quarter turn. Flips need nothing: they are applied after the normalized frame. Adds Orientation::inverse and Orientation::map_norm_rect. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media.rs | 5 +++++ crates/engine/src/tests.rs | 22 +++++++++++++++++++++ crates/geom/src/lib.rs | 30 +++++++++++++++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 19 +++++++++++++----- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 789c880f2..c5860c495 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -711,7 +711,12 @@ impl crate::Session { /// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content. pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option { let p = self.catalog.photo(id)?.clone(); + // `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the + // user's Rotate Left/Right, so carry the box (and the photo's size) over to it. + let orient = p.develop.orientation; + let face = orient.map_norm_rect(face); let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1))); + let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) }; let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7; // finite, within the photo, and never wider than its short side (so the clamps below are valid) let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9becd6c62..2837d6726 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -761,3 +761,25 @@ fn face_job_crops_a_square_inside_the_photo() { assert!((((r.x0 + r.x1) / 2.0) - 0.45).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.375).abs() < 1e-9, "{r:?}"); assert!(s.face_job(lightcraft_catalog::PhotoId(u64::MAX), faces[0], 256).is_none()); } + +/// Regions are on the upright photo; after Rotate Right the close-up still frames the face: the box +/// is carried into the rotated frame the crop lives in, and the crop stays square in rotated pixels. +#[test] +fn face_job_follows_rotate_right() { + use lightcraft_geom::Rect; + let mut s = demo(); + let id = s.active().unwrap(); + let (w, h) = { + let p = s.catalog.photo(id).unwrap(); + (f64::from(p.width), f64::from(p.height)) + }; + // a small face left of centre on the upright photo + let face = Rect { x0: 0.30, y0: 0.45, x1: 0.35, y1: 0.50 }; + s.execute("photo.rotateRight", &json!({})).unwrap(); + let r = s.face_job(id, face, 256).unwrap().settings.crop.geometry.rect; + // a quarter turn clockwise puts the left of the photo at the top: centre (0.325, 0.475) → (0.525, 0.325) + assert!((((r.x0 + r.x1) / 2.0) - 0.525).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.325).abs() < 1e-9, "{r:?}"); + // the rotated photo is h × w pixels + let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w); + assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}"); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 509285aab..327c60fcc 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -332,6 +332,23 @@ impl Orientation { // flipH ∘ rot(t) = rot(-t) ∘ flipH Orientation::from_parts(!f, (4 - t) % 4) } + /// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns + /// are their own inverses; quarter turns swap. + pub fn inverse(self) -> Orientation { + match self { + Orientation::Rotate90 => Orientation::Rotate270, + Orientation::Rotate270 => Orientation::Rotate90, + o => o, + } + } + /// Map a rectangle in normalized (0..1) coordinates of the stored image to normalized coordinates + /// of the oriented one (e.g. a face region given on the upright photo → the frame after the user's + /// Rotate Left/Right). + pub fn map_norm_rect(self, r: Rect) -> Rect { + let (ax, ay) = self.map(r.x0, r.y0, 1.0, 1.0); + let (bx, by) = self.map(r.x1, r.y1, 1.0, 1.0); + Rect::from_points(Point::new(ax, ay), Point::new(bx, by)) + } /// Map a point in stored pixel space (w×h stored dims) to oriented space. pub fn map(self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) { let (flip, t) = self.to_parts(); @@ -417,6 +434,19 @@ mod tests { assert_eq!(Orientation::Rotate90.flipped_h().flipped_h(), Orientation::Rotate90); } + #[test] + fn orientation_inverse_and_norm_rect() { + let r = Rect::new(0.1, 0.2, 0.3, 0.6); + for v in 1..=8 { + let o = Orientation::from_exif(v); + let back = o.inverse().map_norm_rect(o.map_norm_rect(r)); + assert!([back.x0 - r.x0, back.y0 - r.y0, back.x1 - r.x1, back.y1 - r.y1].iter().all(|d| d.abs() < 1e-12), "{o:?}: {back:?}"); + } + // a quarter turn clockwise: the left of the upright photo becomes its top + let q = Orientation::Rotate90.map_norm_rect(r); + assert!([q.x0 - 0.4, q.y0 - 0.1, q.x1 - 0.8, q.y1 - 0.3].iter().all(|d| d.abs() < 1e-12), "{q:?}"); + } + proptest::proptest! { #[test] fn affine_inverse_prop(a in -3.0f64..3.0, sx in 0.1f64..5.0, sy in 0.1f64..5.0, tx in -50.0f64..50.0, px in -20.0f64..20.0, py in -20.0f64..20.0) { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index d50ed5978..cde3efdb1 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -338,7 +338,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { filter_pill(app, ui, canvas); } if app.ui.face_boxes { - match region_overlay(ui, &p, &map, &photo) { + match region_overlay(ui, &p, &map, &photo, d.orientation) { Some(RegionEdit::Remove(index)) => { let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index})); } @@ -458,8 +458,15 @@ const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [ /// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows /// a × in its corner and eight resize handles. A drag previews the new box live and is reported once, /// on release (one undo step); the × reports the region to remove. Both are catalog-only edits: -/// LightCraft doesn't write regions to XMP. -fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lightcraft_catalog::Photo) -> Option { +/// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo; +/// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes. +fn region_overlay( + ui: &egui::Ui, + p: &egui::Painter, + map: &CanvasMap, + photo: &lightcraft_catalog::Photo, + orient: lightcraft_geom::Orientation, +) -> Option { let t = Tokens::get(p.ctx()); let clip = p.clip_rect(); let pointer = ui.input(|i| i.pointer.hover_pos()); @@ -469,7 +476,7 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig let mut edit = None; for (index, r) in photo.meta.regions.iter().enumerate() { let dragging = live.filter(|(i, _)| *i == index); - let norm = dragging.map_or(r.rect, |(_, n)| n); + let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n)); let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1))); // white with a black keyline just outside it, so the box shows on any background p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside); @@ -500,12 +507,14 @@ fn region_overlay(ui: &egui::Ui, p: &egui::Painter, map: &CanvasMap, photo: &lig n.max.y = pp.y.max(n.min.y + 12.0); } let (a, b) = (map.norm(n.min), map.norm(n.max)); - let new = lightcraft_geom::Rect { + let shown = lightcraft_geom::Rect { x0: a.x.min(b.x).clamp(0.0, 1.0), y0: a.y.min(b.y).clamp(0.0, 1.0), x1: a.x.max(b.x).clamp(0.0, 1.0), y1: a.y.max(b.y).clamp(0.0, 1.0), }; + // back to the upright frame the region is stored in + let new = orient.inverse().map_norm_rect(shown); ui.data_mut(|d| d.insert_temp(drag_key, (index, new))); ui.ctx().request_repaint(); } From 862c1f253a167d5d46f1c9da110345beecf57178 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:21:27 -0400 Subject: [PATCH 29/30] LR-LIB-PEOPLE: document MWG face regions in xmp-interop and the parity row Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/xmp-interop.md | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index f9850b6e5..dd5ceaf88 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -145,7 +145,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | -| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; no confirmation dialog, no auto-purge after N days, no "Empty" | diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 017f35691..003b8774d 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -70,6 +70,26 @@ library share a stem, the stem sidecar belongs to one of them — a raw first, o the others write and read `.xmp` (`IMG_0001.JPG.xmp`), falling back to the stem sidecar for reading when they have none. So saving one photo never overwrites the other's metadata (`Session::sidecar_naming`). +## Face regions (MWG) + +LightCraft reads face, pet, focus and barcode regions in the Metadata Working Group's region schema +(`mwg-rs:Regions`, `http://www.metadataworkinggroup.com/schemas/regions/`), as Lightroom, digiKam, Picasa and others +write them, from sidecars and from XMP embedded in the file. Named faces become people in the People view +(LR-LIB-PEOPLE in [`parity.md`](parity.md)), and every region is drawn as a box in the loupe. + +- **Read only.** LightCraft never writes `mwg-rs:Regions`; saving a sidecar keeps another application's regions byte + for byte. Removing or resizing a box in the loupe changes the library only. +- **Areas**: `stArea:x`/`y` are the box's centre, `w`/`h` its size, normalized to the photo (`stArea:unit="pixel"` + areas are divided by `mwg-rs:AppliedToDimensions`, and dropped without it). Boxes are clipped to the photo; one + that misses it entirely, or has a non-finite, zero or negative size, is skipped (the rest of the list is kept). +- **Orientation**: regions are stored on the upright (EXIF-oriented) photo. A `mwg-rs:Rotation` of −π/2, +π/2 or ±π + (how Lightroom marks a box given in the sensor's frame for Exif orientation 6, 8 or 3) is turned back into the + upright frame; MWG can't say "mirrored", so boxes on mirrored photos (orientations 2, 4, 5, 7) are taken as written. + Boxes follow LightCraft's own Rotate Left/Right and flips. +- **Re-reading**: a sidecar that has `mwg-rs:Regions` — even with an empty list — replaces the photo's regions, so + regions removed in another application go away here too. A sidecar without `mwg-rs:Regions` (an application that + doesn't do regions) leaves the photo's regions as they are. + ## Reading `crs:` develop fields Many raw developers store edits as `crs:` properties (`http://ns.adobe.com/camera-raw-settings/1.0/`) in sidecars, in From ca0c5698639e80ea2ccabe5310fcc87395778840 Mon Sep 17 00:00:00 2001 From: sakamoto824 Date: Wed, 7 Oct 2026 06:54:19 -0400 Subject: [PATCH 30/30] LR-BEHAV-LOCALIZE: Traditional Chinese (Taiwan) UI, stock preset and date localisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of PR #198 onto the language table: - Traditional Chinese (Taiwan) catalogs as a table entry: code zh-hant, name 繁體中文(台灣), script Hant; zh-TW / zh-HK / zh-Hant tags reach it; command app.language.traditionalChinese - display-only helpers: builtin_label (stock preset/profile names), history_label (generated history steps, user preset names verbatim), source_label (library headings, album names verbatim), date_group_label / display_time - menu, dialog and panel strings (profiles, presets, curves, crop, versions, history, grid, date sidebar, About links) go through the catalogs - a language command and ui.set apply the language at once; a native-menu test checks that every language switch rebuilds the menu titles Adapted while porting (Claude): - date headings and capture times are localised for every language from its *-formats.json date patterns, not only for zh-TW; ja and zh-hans gain the date patterns, weekday names and "Unknown Date", and zh-hans the new menu messages (ja's come from the PR) - plain catalogs may cover different messages (English fallback); the test reports the gaps instead of failing. The test requiring every engine command label to be translated is dropped - preset groups stay grouped by name: a user preset in a stock-named group is not split into a separate group (the group header is translated when it holds stock presets); the PR's split was a side effect of labelling - no Traditional Chinese face in craft-fonts yet: Hant prefers the Simplified Chinese face (which carries the Traditional characters) over the Japanese ones - zh-hant translations for the messages main added since the PR branched Co-authored-by: Codex Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + ROADMAP.md | 6 +- apps/lightcraft/src/native_menu.rs | 21 + crates/engine/src/fonts.rs | 11 +- crates/ui-egui/locales/ja-formats.json | 17 +- crates/ui-egui/locales/ja.json | 10 +- crates/ui-egui/locales/zh-hans-formats.json | 17 +- crates/ui-egui/locales/zh-hans.json | 10 +- crates/ui-egui/locales/zh-hant-formats.json | 122 ++ crates/ui-egui/locales/zh-hant.json | 1248 +++++++++++++++++++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/i18n.rs | 234 +++- crates/ui-egui/src/menus.rs | 5 + crates/ui-egui/src/panels/dialogs.rs | 10 +- crates/ui-egui/src/panels/edit.rs | 21 +- crates/ui-egui/src/panels/grid.rs | 14 +- crates/ui-egui/src/panels/left.rs | 8 +- crates/ui-egui/src/panels/presets.rs | 17 +- crates/ui-egui/src/panels/profiles.rs | 12 +- crates/ui-egui/src/panels/right.rs | 27 +- docs/localization-zh-hans.md | 2 +- docs/localization-zh-hant.md | 46 + docs/localization.md | 16 +- docs/parity.md | 4 +- 24 files changed, 1804 insertions(+), 78 deletions(-) create mode 100644 crates/ui-egui/locales/zh-hant-formats.json create mode 100644 crates/ui-egui/locales/zh-hant.json create mode 100644 docs/localization-zh-hant.md diff --git a/README.md b/README.md index 654c8e866..9692b6360 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,9 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General → +Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). + The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI (`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md). diff --git a/ROADMAP.md b/ROADMAP.md index 559ad525a..fdfbec0cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | | **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | | **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export | -| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Chinese/Japanese/English UI (see docs/localization.md); remaining technical errors and date formats; accessibility partial; headless UI tests time out under machine load | +| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load | ### By kind of user @@ -87,7 +87,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | | M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) | -| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Chinese/Japanese/English localisation 🟡; accessibility and further locales ⬜) | +| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) | ## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02) @@ -194,7 +194,7 @@ own chart-based calibration (M11.4). Adobe matrices are never used. ## Chinese and Japanese interfaces, and text watermarks -English, Simplified Chinese and Japanese interface languages are persisted in UI state, and the +English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI: diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index d3c9bdb7e..557c9abfb 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -419,6 +419,27 @@ impl NativeMenu { mod tests { use super::*; + /// The menu model's titles stay English, so only the language can tell the native menu to + /// rebuild its titles (and the macOS app menu): every language switch — also between two CJK + /// languages — must change the structure key. + #[test] + fn switching_language_rebuilds_native_menu_structure() { + use lightcraft_ui_egui::i18n::{Locale, set_language}; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + set_language(Locale::En); + let bar = menu_bar(&app); + let mut seen = std::collections::BTreeSet::new(); + for language in Locale::ALL { + set_language(*language); + assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key"); + } + set_language(Locale::ZhHant); + assert_eq!(label_text("File"), "檔案"); + assert_eq!(label_text("Settings…"), "設定…"); + assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft"); + set_language(Locale::En); + } + #[test] fn shortcuts_map_to_accelerators() { assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ))); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index c028ba53e..97a830639 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -26,6 +26,13 @@ impl CraftFont { pub fn is_mincho(&self) -> bool { self.family.contains("Mincho") } + + /// Whether the font can stand in for `script`: it is meant for it, or — until craft-fonts has a + /// Traditional Chinese face — it is a Simplified Chinese face, which carries the Traditional + /// characters too (with mainland glyph forms; still closer than the Japanese faces). + pub fn serves(&self, script: &str) -> bool { + self.covers(script) || (script == "Hant" && self.covers("Hans")) + } } /// The craft-fonts faces for Japanese, in `fonts`' order (empty without craft-fonts). @@ -55,7 +62,7 @@ pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Ve ( // The language's own script first. A face dedicated to the script is preferred over a // multi-script face (Noto Sans CJK SC claims `Hans,Latn`; a Japanese face claims `Jpan`). - !font.covers(script), + !font.serves(script), font.scripts.len() != 1, font.family != "BIZ UDPGothic", font.style != style, @@ -99,6 +106,8 @@ mod tests { let first = |script: &str| cjk_fallback(FACES, script, "Regular").first().map(|f| f.family); assert_eq!(first("Hans"), Some("Noto Sans CJK SC")); assert_eq!(first("Jpan"), Some("BIZ UDPGothic")); + // No Traditional Chinese face yet: the Chinese face stands in before the Japanese ones. + assert_eq!(first("Hant"), Some("Noto Sans CJK SC")); // Every CJK face is still offered to every language. assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3); assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0); diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 354205c8d..1944d7b6a 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -103,5 +103,20 @@ "Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚", " ({} in Recently Deleted)": "(うち{}枚は最近削除した写真)", "Already in the library: {} photo{} selected in All Photos": "ライブラリに登録済み:すべての写真で{}枚を選択しました{:.0}", - "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し" + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し", + "Auto ({})": "自動 ({})", + "Build 1:1 Previews ({scope})": "1:1プレビューを作成({scope})", + "Build Smart Previews ({scope})": "スマートプレビューを作成({scope})", + "Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})", + "Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})", + "Label Set: {name}": "ラベルセット:{name}", + "Quick Develop: {name}": "クイック現像: {name}", + "Reset {name}": "{name}をリセット", + "Updated “{name}”": "「{name}」を更新しました", + "{month}": "{month}月", + "{month} {year}": "{year}年{month}月", + "{verb} {n} Photo{}": "{n}枚を{verb}{:.0}", + "{weekday}, {day}": "{day}日({weekday})", + "{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})", + "{year}": "{year}年" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9c09bbb91..f131d0b46 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1019,5 +1019,13 @@ "Back Up Library…": "ライブラリをバックアップ…", "Restore Library from Backup…": "バックアップからライブラリを復元…", "In Recently Deleted": "最近削除した写真内", - "Reload from Disk": "ディスクから再読み込み" + "Reload from Disk": "ディスクから再読み込み", + "Monday": "月曜日", + "Tuesday": "火曜日", + "Wednesday": "水曜日", + "Thursday": "木曜日", + "Friday": "金曜日", + "Saturday": "土曜日", + "Sunday": "日曜日", + "Unknown Date": "日付不明" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index c30f92ea0..c3713eb18 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -103,5 +103,20 @@ "Adding photos… {} of {}": "正在添加照片… 第 {0} 张,共 {1} 张", " ({} in Recently Deleted)": "(其中 {0} 张在“最近删除”中)", "Already in the library: {} photo{} selected in All Photos": "已在图库中:已在“所有照片”中选择 {0} 张照片{1:.0}", - "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入" + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入", + "Auto ({})": "自动({})", + "Build 1:1 Previews ({scope})": "生成 1:1 预览({scope})", + "Build Smart Previews ({scope})": "生成智能预览({scope})", + "Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})", + "Discard Smart Previews ({scope})": "丢弃智能预览({scope})", + "Label Set: {name}": "标签集:{name}", + "Quick Develop: {name}": "快速编辑:{name}", + "Reset {name}": "重置{name}", + "Updated “{name}”": "已更新“{name}”", + "{verb} {n} Photo{}": "{verb} {n} 张照片{:.0}", + "{year}": "{year}年", + "{month}": "{month}月", + "{month} {year}": "{year}年{month}月", + "{weekday}, {day}": "{day}日 {weekday}", + "{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 5cac9dfef..7dc4d7c3b 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1019,5 +1019,13 @@ "Back Up Library…": "备份图库…", "Restore Library from Backup…": "从备份恢复图库…", "In Recently Deleted": "在“最近删除”中", - "Reload from Disk": "从磁盘重新读取" + "Reload from Disk": "从磁盘重新读取", + "Monday": "星期一", + "Tuesday": "星期二", + "Wednesday": "星期三", + "Thursday": "星期四", + "Friday": "星期五", + "Saturday": "星期六", + "Sunday": "星期日", + "Unknown Date": "未知日期" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json new file mode 100644 index 000000000..816936c0f --- /dev/null +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -0,0 +1,122 @@ +{ + " · {skipped} skipped (file exists)": " · 已略過 {skipped} 個(檔案已存在)", + " · {} duplicate{} skipped": " · 已略過 {} 張重複照片{:.0}", + " · {} failed: {}": " · {} 個失敗:{}", + " · {} moved": " · 已移動 {} 個", + " · {} not readable": " · {} 個無法讀取", + ", {failed} file{} not readable": ",{failed} 個檔案無法讀取{:.0}", + "Add {local_n} to My Photos": "將 {local_n} 張加入我的照片", + "Add {} Photo{}": "新增 {} 張照片{:.0}", + "Add “{}” to Favorites": "將「{}」加入喜好項目", + "Added {n} photo{} to My Photos": "已將 {n} 張照片加入我的照片{:.0}", + "Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}", + "Adding photos… {} of {}": "正在新增照片… {}/{}", + "All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。", + "Auto ({})": "自動({})", + "Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片", + "Automatic ({} MB)": "自動({} MB)", + "Build 1:1 Previews ({scope})": "建立 1:1 預覽({scope})", + "Build Smart Previews ({scope})": "建立智慧型預覽({scope})", + "Build Standard-Sized Previews ({scope})": "建立標準尺寸預覽({scope})", + "Click {}:1": "點擊時 {}:1", + "Create {n} Virtual Copies": "建立 {n} 張虛擬副本", + "Decoder: {reason}": "解碼器:{reason}", + "Delete \"{label}\"": "刪除「{label}」", + "Delete \"{label}\" (and the mask)": "刪除「{label}」及遮色片", + "Delete {n} Photos": "刪除 {n} 張照片", + "Delete “{current}”": "刪除「{current}」", + "Discard Smart Previews ({scope})": "捨棄智慧型預覽({scope})", + "Duplicate \"{label}\"": "複製「{label}」", + "Example: {example}": "範例:{example}", + "Exported {n} photo{}": "已匯出 {n} 張照片{:.0}", + "Exported {ok} of {total} photo{}": "已匯出 {ok}/{total} 張照片{:.0}", + "Exported {} point curve preset{}": "已匯出 {} 個點曲線預設集{:.0}", + "Exported {} preset{}": "已匯出 {} 個預設集{:.0}", + "Exporting {} of {total}": "正在匯出第 {}/{total} 張", + "Filter bar — {active} active filter{}": "篩選列 — 已套用 {active} 個篩選條件{:.0}", + "Healed {n} dust spot{}": "已修復 {n} 個灰塵斑點{:.0}", + "Import cancelled · {} photo{} added": "已取消匯入 · 已加入 {} 張照片{:.0}", + "Imported {n} point curve preset{}": "已匯入 {n} 個點曲線預設集{:.0}", + "Imported {n} preset{}": "已匯入 {n} 個預設集{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "已匯入 {n} 個預設集{:.0},{f} 個檔案無法讀取{:.0}", + "Imported {} photo{}": "已匯入 {} 張照片{:.0}", + "Label Set: {name}": "標籤集:{name}", + "Matches: {}": "符合條件:{}", + "Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?", + "Named ({named_n})": "已命名({named_n})", + "Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定", + "Preset: {name}": "預設集:{name}", + "Preview only — editing the camera's embedded JPEG ({})": "僅供預覽 — 正在編輯相機內嵌的 JPEG({})", + "Preview only — {}": "僅供預覽 — {}", + "Profile: {}": "描述檔:{}", + "Proof Copy ({})": "校樣副本({})", + "Proof Preview · {}": "校樣預覽 · {}", + "Quick Develop · {n} photos": "快速顯像 · {n} 張照片", + "Quick Develop: {name}": "快速顯像:{name}", + "Rated {}": "評分:{}", + "Reading photos… {done} of {total}": "正在讀取照片… {done}/{total}", + "Reading photos… {} of {}": "正在讀取照片… {}/{}", + "Redo {}": "重做 {}", + "Remove “{}” from Favorites": "將「{}」從喜好項目移除", + "Rename {n} Photos…": "重新命名 {n} 張照片…", + "Rename “{name}”": "重新命名「{name}」", + "Rendering on the CPU: {why}": "使用 CPU 算繪:{why}", + "Reset {name}": "重設{name}", + "Reset {} Channel": "重設 {} 通道", + "Saved export preset “{}”": "已儲存匯出預設集「{}」", + "Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}", + "Showing {n} of {}": "顯示 {n}/{} 張", + "Smart album: {}": "智慧型相簿:{}", + "Undo {}": "復原 {}", + "Updated “{name}”": "已更新「{name}」", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {} — 以純 Rust 獨立實作的照片管理與 RAW 顯像程式。", + "Version: {}": "版本:{}", + "none (CPU rendering): {why}": "無(使用 CPU 算繪):{why}", + "the {n} selected photos": "選取的 {n} 張照片", + "the {} photos in view": "目前顯示的 {} 張照片", + "these {count} photos": "這 {count} 張照片", + "{count} smart preview{} · {:.1} MB": "{count} 張智慧型預覽{:.0} · {:.1} MB", + "{f} folders (and their subfolders)": "{f} 個資料夾(及其子資料夾)", + "{label} Grading": "{label}色彩分級", + "{label} {d:+} on every selected photo": "每張選取照片的{label}調整 {d:+}", + "{label}, {n} photos": "{label},{n} 張照片", + "{matching} of {t} photos": "{matching}/{t} 張照片", + "{matching} photos": "{matching} 張照片", + "{mode} spot {} of {}": "{mode}斑點:第 {}/{} 個", + "{month}": "{month}月", + "{month} {year}": "{year}年{month}月", + "{msg} ({failed} failed)": "{msg}({failed} 個失敗)", + "{name} Label": "{name}標籤", + "{name} Off": "關閉{name}", + "{name} On": "開啟{name}", + "{name} — double-click to reset it": "{name} — 按兩下可重設", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 項變更{:.0}已保留在記憶體,但尚未寫入磁碟:{e}\nLightCraft 會自動重試;現在結束程式會遺失{}。", + "{n} found · {sel} selected": "找到 {n} 張 · 已選取 {sel} 張", + "{n} photos": "{n} 張照片", + "{n} photos in {} are hidden by the filters above": "{}中的 {n} 張照片被上方篩選條件隱藏", + "{n} photo{}": "{n} 張照片{:.0}", + "{n} photo{} match · updates automatically as photos change": "{n} 張照片符合{:.0} · 隨照片變更自動更新", + "{n} photo{} now · updates automatically as photos change": "目前 {n} 張照片{:.0} · 隨照片變更自動更新", + "{sel_n} selected · {counted}": "已選取 {sel_n} 張 · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} {n} 張照片{:.0}", + "{weekday}, {day}": "{day}日 {weekday}", + "{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}", + "{what} merge added": "已新增{what}合併照片", + "{what} merge failed: {e}": "{what}合併失敗:{e}", + "{year}": "{year}年", + "{} and higher": "{} 以上", + "{} files": "{} 個檔案", + "{} files and {f} folder{}": "{} 個檔案與 {f} 個資料夾{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已繪製 {}/4 條參考線 — 沿應為垂直或水平的線條拖曳。", + "{} photos": "{} 張照片", + "{} photo{} not readable": "{} 張照片無法讀取{:.0}", + "{} spot(s) on this photo": "此照片有 {} 個斑點", + "· {dups} already in the library": "· {dups} 張已在照片庫中", + "· {n} photo{}": "· {n} 張照片{:.0}", + "… and {more} more": "…以及另外 {more} 個", + " ({} in Recently Deleted)": "(其中 {} 張在「最近刪除」中)", + "Already in the library: {} photo{} selected in All Photos": "已在照片庫中:已在「所有照片」中選取 {} 張照片{:.0}", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在照片庫的「最近刪除」中:已選取 {} 張照片{:.0} · 按右鍵 ▸ 還原,或永久刪除後重新匯入" +} diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json new file mode 100644 index 000000000..79c952917 --- /dev/null +++ b/crates/ui-egui/locales/zh-hant.json @@ -0,0 +1,1248 @@ +{ + "+ Group": "+ 群組", + "+ Rule": "+ 規則", + "0: nothing is rejected.": "0:不排除任何照片。", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "篩選條件隱藏了此檢視的照片:請變更條件,或選擇「檢視 → 清除篩選條件」", + "About LightCraft": "關於 LightCraft", + "Activity": "處理狀態", + "Actual size": "實際大小", + "Add": "新增", + "Add Catchlight": "新增眼神光", + "Add Folder to Local": "將資料夾加入本機", + "Add Point Color Sample": "新增點色彩樣本", + "Add Red Eye Correction": "新增紅眼校正", + "Add Remove Spot": "新增移除斑點", + "Add Selected Photos": "新增選取的照片", + "Add a rule (⌥-click: a group)": "新增規則(⌥ 點擊:新增群組)", + "Add in place": "原地加入", + "Add in place: the library references the files where they are; nothing is copied or moved.": "原地加入:照片庫參照檔案目前的位置,不會複製或移動檔案。", + "Add keyword": "新增關鍵字", + "Add number": "加上編號", + "Add to": "加入至", + "Add to Album": "加入相簿", + "Add to Favorites": "加入喜好項目", + "Add to My Photos": "加入我的照片", + "Add to Selected Photos": "加入選取的照片", + "Add to Target Album": "加入目標相簿", + "Add/Subtract/Intersect Mask": "新增/減去/交集遮色片", + "Added to photos you import that don't already have it.": "只會加入至匯入時尚未具有這些資訊的照片。", + "Adding": "正在新增", + "Advanced search…": "進階搜尋…", + "After": "編輯後", + "After 30 days": "30 天後", + "After 7 days": "7 天後", + "After 90 days": "90 天後", + "After a year": "一年後", + "Album": "相簿", + "Albums": "相簿", + "All": "全部", + "All Metadata": "所有中繼資料", + "All Metadata…": "所有中繼資料…", + "All Photos": "所有照片", + "All rights reserved": "保留所有權利", + "All types": "所有類型", + "Alt Text": "替代文字", + "Always": "一律", + "Amount": "強度", + "Aperture": "光圈", + "Application": "應用程式", + "Apply Color Label Set": "套用色彩標籤集", + "Apply Filter Preset": "套用篩選預設集", + "Apply Metadata Preset": "套用中繼資料預設集", + "Apply Point Curve Preset": "套用點曲線預設集", + "Apply Preset": "套用預設集", + "Apply Settings JSON": "套用 JSON 設定", + "Approved": "已核准", + "Aqua": "青色", + "Aqua Hue": "青色色相", + "Aqua Luminance": "青色明度", + "Aqua Saturation": "青色飽和度", + "ArtCraft Website": "ArtCraft 網站", + "ArtCraft — more creative apps": "ArtCraft — 更多創作應用程式", + "As Shot": "拍攝時設定", + "As shot": "拍攝時設定", + "Ascending": "遞增", + "Aspect": "長寬比", + "Aspect Ratio": "長寬比", + "Assisted Culling": "輔助挑片", + "Assisted Culling…": "輔助挑片…", + "Auto": "自動", + "Auto Advance": "自動前進", + "Auto Advance Off": "關閉自動前進", + "Auto Advance On": "開啟自動前進", + "Auto Advance after rating or flagging": "評分或標記後自動前進", + "Auto Advance: move to the next photo after rating or flagging": "自動前進:評分或標記後移至下一張照片", + "Auto Align": "自動對齊", + "Auto B&W Mix": "自動黑白混色", + "Auto Crop": "自動裁切", + "Auto Import": "自動匯入", + "Auto Import Now": "立即自動匯入", + "Auto Import Settings": "自動匯入設定", + "Auto Mask": "自動遮色片", + "Auto Settings": "自動設定", + "Auto Straighten": "自動拉直", + "Auto Sync": "自動同步", + "Auto-Stack by Capture Time": "依拍攝時間自動堆疊", + "Auto-Stack by Capture Time…": "依拍攝時間自動堆疊…", + "Auto-Tag Photos from Tracklog": "依軌跡記錄自動標記照片位置", + "Auto-Tag from Tracklog": "依軌跡記錄自動加入位置標記", + "Auto-Tag from Tracklog…": "依軌跡記錄自動加入位置標記…", + "Automatic": "自動", + "Automatically Write Changes into XMP": "自動將變更寫入 XMP", + "Automatically write changes into XMP sidecars": "自動將變更寫入 XMP 附屬檔案", + "Autumn Gold": "秋日金色", + "B&W": "黑白", + "B&W Mix": "黑白混色", + "B&W Mixer": "黑白混色器", + "Back": "返回", + "Back Up Library…": "備份照片庫…", + "Back to Edit": "返回編輯", + "Back to Edit (E)": "返回編輯(E)", + "Back to Grid": "返回格狀檢視", + "Background": "背景", + "Balance": "平衡", + "Basic": "基本", + "Before": "編輯前", + "Before/After (Y)": "編輯前/後(Y)", + "Before/After Settings": "編輯前/後設定", + "Before/After Split": "編輯前/後分割檢視", + "Before/After Split Top/Bottom": "編輯前/後上下分割檢視", + "Before/After Top/Bottom": "編輯前/後上下檢視", + "Begin Interaction": "開始互動", + "Bit depth": "位元深度", + "Black & White": "黑白", + "Blacks": "黑色色階", + "Bleach Bypass": "漂白略過", + "Bleached": "漂白效果", + "Blending": "混合", + "Blue": "藍色", + "Blue Hour Boost": "藍調時刻增強", + "Blue Hue": "藍色色相", + "Blue Luminance": "藍色明度", + "Blue Primary": "藍色原色", + "Blue Saturation": "藍色飽和度", + "Blue channel": "藍色通道", + "Blue/Yellow Fringe": "藍/黃色邊緣", + "Blurry photos below the score are flagged as rejects.": "焦點分數低於門檻的模糊照片會標記為排除。", + "Boundary Warp": "邊界變形", + "Bright & Airy": "明亮輕盈", + "Bring All to Front": "將所有視窗移至最前方", + "Browse Folder": "瀏覽資料夾", + "Browse Folder…": "瀏覽資料夾…", + "Browse Profiles": "瀏覽描述檔", + "Browse…": "瀏覽…", + "Brush": "筆刷", + "Brush Settings": "筆刷設定", + "Brush Stroke": "筆刷筆觸", + "Build Previews": "建立預覽", + "Build Smart Previews": "建立智慧型預覽", + "By Date": "依日期", + "By day (YYYY/YYYY-MM-DD)": "依日期(YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "依月份(YYYY/YYYY-MM)", + "Calibration": "校正", + "Camera": "相機", + "Cameras and Cards": "相機與記憶卡", + "Cancel": "取消", + "Cancel Interaction": "取消互動", + "Cancel Preview Build": "取消建立預覽", + "Candidate": "候選照片", + "Caption": "圖說", + "Capture Date": "拍攝日期", + "Captured": "拍攝時間", + "Catalog Statistics": "目錄統計", + "Check All": "全部勾選", + "Check Output Path": "檢驗輸出路徑", + "Choose Another Library…": "選擇其他照片庫…", + "Choose Edit Settings to Copy": "選擇要複製的編輯設定", + "Choose Edit Settings to Copy…": "選擇要複製的編輯設定…", + "Choose Folder…": "選擇資料夾…", + "Choose a photo in the grid or filmstrip": "請在格狀檢視或底片顯示窗格中選擇照片", + "Choose…": "選擇…", + "Cinematic": "電影風格", + "City": "城市", + "Clarity": "清晰度", + "Clean Studio": "純淨棚拍", + "Clear": "清除", + "Clear Cache": "清除快取", + "Clear Filters": "清除篩選條件", + "Clear Guides": "清除參考線", + "Clear History": "清除歷史紀錄", + "Clear Preview Cache": "清除預覽快取", + "Clear Quick Collection": "清除快速集合", + "Clear Reference": "清除參考照片", + "Clear all": "全部清除", + "Clear form": "清除表單", + "Cleared History": "已清除歷史紀錄", + "Click Zoom Ratio": "點擊縮放比例", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "點擊標籤即可插入。範例使用 2026 年 1 月 14 日 05:58:48 拍攝的 IMG_0042.CR3。", + "Click photos in the grid to toggle the keyword; Esc stops": "點擊格狀檢視中的照片以加入或移除關鍵字;按 Esc 結束", + "Click the photo to pick a colour · ⇧-click adds more": "點擊照片以選取色彩;⇧ 點擊可新增更多色彩", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "點擊照片以選取色彩;⇧ 點擊可新增更多色彩(最多 5 個)", + "Clone": "仿製", + "Close": "關閉", + "Close Panel": "關閉面板", + "Close Window": "關閉視窗", + "Close all": "全部關閉", + "Close file": "關閉檔案", + "Cloudy": "陰天", + "Collapse All Stacks": "收合所有堆疊", + "Color": "色彩", + "Color Grading": "色彩分級", + "Color Label Names": "色彩標籤名稱", + "Color Label Sets": "色彩標籤集", + "Color Mixer": "混色器", + "Color Noise Reduction": "色彩雜訊抑制", + "Color Priority": "色彩優先", + "Color Range": "色彩範圍", + "Color space": "色彩空間", + "Color space: sRGB (AVIF)": "色彩空間:sRGB(AVIF)", + "Combine files…": "合併檔案…", + "Command Journal": "指令記錄", + "Comments panel": "註解面板", + "Compare": "比較", + "Compare (Shift+C)": "比較(Shift+C)", + "Compare Before and After": "比較編輯前與編輯後", + "Compare needs at least two photos": "比較需要至少兩張照片", + "Component options": "元件選項", + "Compression": "壓縮", + "Concrete Cool": "冷調混凝土", + "Confirm before deleting": "刪除前確認", + "Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認", + "Constrain Crop": "限制裁切範圍", + "Continue Without Saving": "繼續使用但不儲存", + "Contrast": "對比", + "Convert to DNG": "轉換為 DNG", + "Cool Fade": "冷調褪色", + "Cool Morning": "清冷晨光", + "Copy": "複製", + "Copy After's Settings to Before": "將編輯後設定複製至編輯前", + "Copy Before's Settings to After": "將編輯前設定複製至編輯後", + "Copy Edit Settings": "複製編輯設定", + "Copy History Step Settings to Before": "將歷史步驟設定複製至編輯前", + "Copy Metadata": "複製中繼資料", + "Copy as DNG": "複製為 DNG", + "Copy into the library (else use the files where they are)": "複製至照片庫(否則使用原位置的檔案)", + "Copy pages": "複製頁面", + "Copy the files (into the library's Originals/, or a folder you choose)": "複製檔案(至照片庫的 Originals/ 或指定資料夾)", + "Copy to": "複製至", + "Copy to Clipboard": "複製到剪貼簿", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "複製:檔案會複製至下方資料夾,照片庫使用複本,原始檔案維持原狀。", + "Copyright": "著作權", + "Copyright Info URL": "著作權資訊網址", + "Copyright Status": "著作權狀態", + "Country": "國家", + "Create Album": "建立相簿", + "Create Album…": "建立相簿…", + "Create Folder": "建立資料夾", + "Create Folder…": "建立資料夾…", + "Create New Mask": "建立新遮色片", + "Create PDF from clipboard": "從剪貼簿建立 PDF", + "Create PDF from file…": "從檔案建立 PDF…", + "Create PDF from images…": "從圖片建立 PDF…", + "Create Preset": "建立預設集", + "Create Preset…": "建立預設集…", + "Create Proof Copy": "建立校樣副本", + "Create Smart Album": "建立智慧型相簿", + "Create Smart Album from Filter…": "依篩選條件建立智慧型相簿…", + "Create Smart Album…": "建立智慧型相簿…", + "Create Stack": "建立堆疊", + "Create Version": "建立版本", + "Create Version from Step": "依此步驟建立版本", + "Create Virtual Copy": "建立虛擬副本", + "Creator": "創作者", + "Crisp Landscape": "清晰風景", + "Crisp Vista": "清晰遠景", + "Crop": "裁切", + "Crop & Rotate": "裁切與旋轉", + "Crop & Rotate (C)": "裁切與旋轉(C)", + "Crop Aspect": "裁切長寬比", + "Cross Process": "交叉沖洗", + "Culling": "挑片", + "Curve": "曲線", + "Custom": "自訂", + "Custom…": "自訂…", + "Cut": "剪下", + "Cut pages": "剪下頁面", + "Cycle Crop Overlay": "切換裁切疊加", + "Cycle Crop Overlay Orientation": "切換裁切疊加方向", + "Cycle Info Overlay": "切換資訊疊加", + "Cycle Mask Overlay Color": "切換遮色片疊加色彩", + "Cycle Mask Overlay Mode": "切換遮色片疊加模式", + "Cylindrical": "圓柱形", + "Dark & Moody": "暗調氛圍", + "Darken": "變暗", + "Darks": "暗部", + "Date Groups": "日期群組", + "Day": "日", + "Daylight": "日光", + "Decrease Brush Feather": "減少筆刷羽化", + "Decrease Brush Size": "縮小筆刷尺寸", + "Deep Sky": "深邃天空", + "Defringe": "去除邊緣色差", + "Deghost Amount": "去除鬼影強度", + "Dehaze": "去朦朧", + "Delete": "刪除", + "Delete (⌫)": "刪除(⌫)", + "Delete Album": "刪除相簿", + "Delete All Masks": "刪除所有遮色片", + "Delete Color Label Set": "刪除色彩標籤集", + "Delete Export Preset": "刪除匯出預設集", + "Delete Filter Preset": "刪除篩選預設集", + "Delete Imported Profile": "刪除匯入的描述檔", + "Delete Keyword": "刪除關鍵字", + "Delete Keyword Set": "刪除關鍵字集", + "Delete Mask": "刪除遮色片", + "Delete Metadata Preset": "刪除中繼資料預設集", + "Delete Permanently": "永久刪除", + "Delete Photo": "刪除照片", + "Delete Photos": "刪除照片", + "Delete Point Color Sample": "刪除點色彩樣本", + "Delete Point Curve Preset": "刪除點曲線預設集", + "Delete Preset": "刪除預設集", + "Delete Red Eye Correction": "刪除紅眼校正", + "Delete Spot": "刪除斑點", + "Delete Version": "刪除版本", + "Delete all": "全部刪除", + "Delete all spots": "刪除所有斑點", + "Delete mask": "刪除遮色片", + "Delete them": "刪除這些照片", + "Density": "濃度", + "Depth Range": "深度範圍", + "Descending": "遞減", + "Deselect": "取消選取", + "Deselect All": "取消全選", + "Desert Heat": "沙漠熱浪", + "Desert Warmth": "沙漠暖色", + "Detail": "詳細檢視", + "Detail (D)": "詳細檢視(D)", + "Diagonal": "對角線", + "Discard Preview Cache": "捨棄預覽快取", + "Display P3": "Display P3", + "Display theme": "顯示主題", + "Distortion": "變形", + "Document properties…": "文件內容…", + "Don't enlarge": "不放大", + "Done": "完成", + "Drag along the horizon; double-click for Auto": "沿水平線拖曳;按兩下可自動校正", + "Drag over an eye on the photo; the pupil inside is found automatically.": "在照片的眼睛上拖曳,會自動尋找範圍內的瞳孔。", + "Draw Guides": "繪製參考線", + "Duplicate": "複製", + "Duplicate Mask": "複製遮色片", + "Duplicate and Invert Mask": "複製並反轉遮色片", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "每個 / 代表一層資料夾;標籤會依照片填入(缺少中繼資料的層級使用「unknown」)。", + "Edit": "編輯", + "Edit (E)": "編輯(E)", + "Edit Capture Time": "編輯拍攝時間", + "Edit Capture Time…": "編輯拍攝時間…", + "Edit Color Label Names": "編輯色彩標籤名稱", + "Edit Color Label Names…": "編輯色彩標籤名稱…", + "Edit Copy for External Editor": "編輯外部編輯器副本", + "Edit Info": "編輯資訊", + "Edit Label Names…": "編輯標籤名稱…", + "Edit Mask Component": "編輯遮色片元件", + "Edit Sections": "編輯區段", + "Edit Smart Album": "編輯智慧型相簿", + "Edit Smart Album…": "編輯智慧型相簿…", + "Edit Spot": "編輯斑點", + "Edit in External Editor": "在外部編輯器中編輯", + "Edited": "已編輯", + "Effect": "效果", + "Effects": "效果", + "Enable Lens Corrections": "啟用鏡頭校正", + "End Interaction": "結束互動", + "English": "English", + "Enter Full Screen": "進入全螢幕", + "Erase": "擦除", + "Every EXIF, GPS and XMP field in the file": "檔案中的所有 EXIF、GPS 與 XMP 欄位", + "Existing previews": "現有預覽", + "Exit": "結束", + "Expand All Stacks": "展開所有堆疊", + "Expand/Collapse Stack": "展開/收合堆疊", + "Export": "匯出", + "Export Album with Preset": "使用預設集匯出相簿", + "Export Album…": "匯出相簿…", + "Export As…": "匯出為…", + "Export Group…": "匯出群組…", + "Export Now": "立即匯出", + "Export Point Curve Presets": "匯出點曲線預設集", + "Export Point Curve Presets…": "匯出點曲線預設集…", + "Export Presets": "匯出預設集", + "Export Presets…": "匯出預設集…", + "Export User Presets…": "匯出使用者預設集…", + "Export with Preset": "使用預設集匯出", + "Export with Previous": "使用上次設定匯出", + "Exporting": "正在匯出", + "Export…": "匯出…", + "Exposure": "曝光", + "Exposure (shutter · aperture · ISO)": "曝光(快門 · 光圈 · ISO)", + "Extended Description": "延伸說明", + "External Editor": "外部編輯器", + "Faded Matte": "褪色霧面", + "Faded Slide": "褪色正片", + "Faded Urban": "褪色城市", + "Favorite Preset": "將預設集加入喜好項目", + "Favorite Profile": "將描述檔加入喜好項目", + "Favorites": "喜好項目", + "Feather": "羽化", + "File": "檔案", + "File & date": "檔案與日期", + "File Name": "檔案名稱", + "File Path": "檔案路徑", + "File Size": "檔案大小", + "File name": "檔案名稱", + "Fill Edges": "填補邊緣", + "Film": "底片", + "Filmstrip": "底片顯示窗格", + "Filmstrip (/)": "底片列(/)", + "Filter": "篩選", + "Filter Bar": "篩選列", + "Filter Presets": "篩選預設集", + "Filter bar": "篩選列", + "Filter fields": "篩選欄位", + "Find Dust Spots": "尋找灰塵斑點", + "Find Missing Photos": "尋找遺失的照片", + "Find Missing Photos…": "尋找遺失的照片…", + "Find Similar Photos": "尋找相似照片", + "Find tools and commands…": "尋找工具與指令…", + "Find…": "尋找…", + "Fit": "符合視窗", + "Fit (F)": "符合視窗(F)", + "Fit on Screen": "符合螢幕", + "Fit to width": "符合寬度", + "Fit visible": "符合可見範圍", + "Flag": "旗標", + "Flag as Pick": "標記為精選", + "Flag as Reject": "標記為排除", + "Flash": "閃光燈", + "Flip H": "水平翻轉", + "Flip Horizontal": "水平翻轉", + "Flip Overlay (⇧O)": "翻轉疊加(⇧O)", + "Flip V": "垂直翻轉", + "Flip Vertical": "垂直翻轉", + "Flow": "流量", + "Fluorescent": "螢光燈", + "Focal length": "焦距", + "Folder": "資料夾", + "Folder name (renamed on disk; its photos follow)": "資料夾名稱(會在磁碟上重新命名,照片也會隨之移動)", + "Folders": "資料夾", + "Folders and {date} use the capture time; a photo without one uses today's date.": "資料夾與 {date} 使用拍攝時間;沒有拍攝時間的照片使用今天的日期。", + "Food": "美食", + "Forget Unchanged Local Photos": "忘記未變更的本機照片", + "Forget unchanged photos": "忘記未變更的照片", + "Form fields panel": "表單欄位面板", + "Format": "格式", + "Forward": "前進", + "Free": "自由", + "Fresh & Bright": "清新明亮", + "Friday": "星期五", + "Full": "完整", + "Full Screen Preview": "全螢幕預覽", + "Full screen mode": "全螢幕模式", + "Full size": "完整尺寸", + "GPS Track Log": "GPS 軌跡記錄", + "GPU Rendering": "GPU 算繪", + "General": "一般", + "Geometry": "幾何", + "Get Develop Settings": "取得顯像設定", + "Global Hue": "整體色相", + "Global Luminance": "整體明度", + "Global Saturation": "整體飽和度", + "Glossy": "亮面", + "Go to History Step": "前往歷史步驟", + "Golden": "黃金比例", + "Golden Glow": "金色光暈", + "Golden Hour": "黃金時刻", + "Grain": "顆粒", + "Graphic": "圖像", + "Green": "綠色", + "Green Amount": "綠色強度", + "Green Hue": "綠色色相", + "Green Hue High": "綠色色相上限", + "Green Hue Low": "綠色色相下限", + "Green Luminance": "綠色明度", + "Green Primary": "綠色原色", + "Green Saturation": "綠色飽和度", + "Green channel": "綠色通道", + "Grid": "格狀檢視", + "Grid Info": "格狀檢視資訊", + "Gritty Street": "粗礪街頭", + "Group": "群組", + "Group by Date: Automatic": "依日期分組:自動", + "Group by Day": "依日分組", + "Group by Month": "依月分組", + "Group by Year": "依年分組", + "Group by date": "依日期分組", + "Group into Stack": "分組為堆疊", + "Guided": "引導式", + "Guided Upright": "引導式透視校正", + "Guided Upright Guides": "引導式透視校正參考線", + "HDR Merge": "HDR 合併", + "HDR Panorama Merge": "HDR 全景合併", + "HDR Panorama with Last Settings": "使用上次設定合併 HDR 全景", + "HDR Panorama…": "HDR 全景…", + "HDR with Last Settings": "使用上次設定合併 HDR", + "HDR…": "HDR…", + "Heal": "修復", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點", + "Height": "高度", + "Height (px)": "高度(像素)", + "Help": "說明", + "Hide LightCraft": "隱藏 LightCraft", + "Hide Mask": "隱藏遮色片", + "Hide Others": "隱藏其他應用程式", + "Hides this shortcut only; the folder and its photos stay as they are": "只隱藏此捷徑,資料夾及照片維持原狀", + "High": "高", + "High Contrast B&W": "高對比黑白", + "Highlight": "亮部", + "Highlights": "亮部", + "Highlights Hue": "亮部色相", + "Highlights Luminance": "亮部明度", + "Highlights Saturation": "亮部飽和度", + "Highlights split": "亮部分界", + "Histogram": "色階分佈圖", + "History": "歷史紀錄", + "History & Activity (Y)": "歷史紀錄與處理狀態(Y)", + "Horizontal": "水平", + "Horizontal text": "水平文字", + "Hue": "色相", + "Hue Range": "色相範圍", + "If file exists": "檔案已存在時", + "Image": "圖片", + "Import": "匯入", + "Import Date": "匯入日期", + "Import Photos": "匯入照片", + "Import Photos…": "匯入照片…", + "Import Point Curve Presets": "匯入點曲線預設集", + "Import Point Curve Presets…": "匯入點曲線預設集…", + "Import Presets": "匯入預設集", + "Import Presets…": "匯入預設集…", + "Import Profiles": "匯入描述檔", + "Import Profiles & Presets…": "匯入描述檔與預設集…", + "Import from Device": "從裝置匯入", + "Import from Folder…": "從資料夾匯入…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "使用「檔案 → 匯入照片…」(Cmd+Shift+I),或將照片拖放至此", + "Imported Photos": "已匯入的照片", + "Importing": "正在匯入", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "匯入會掃描來源中的照片,不會將資料夾加入本機(若要直接使用資料夾中的檔案,請選擇「本機 → 瀏覽資料夾…」)。", + "In library": "在照片庫中", + "In memory — nothing is saved": "僅存於記憶體,尚未儲存", + "In use": "使用中", + "In-memory session: these settings last until LightCraft quits.": "記憶體工作階段:這些設定只保留至 LightCraft 結束。", + "Include subfolders": "包含子資料夾", + "Increase Brush Feather": "增加筆刷羽化", + "Increase Brush Size": "放大筆刷尺寸", + "Info": "資訊", + "Info (I)": "資訊(I)", + "Info overlay": "資訊疊加", + "Inspect Photo": "檢驗照片", + "Instant ’70s": "七〇年代拍立得", + "Interface": "介面", + "Interface language": "介面語言", + "Intersect": "交集", + "Intersect with": "與下列項目取交集", + "Into one folder": "放入同一資料夾", + "Invert": "反轉", + "Invert Mask": "反轉遮色片", + "JPEG (Large)": "JPEG(大尺寸)", + "JPEG (Small)": "JPEG(小尺寸)", + "Join the ArtCraft Discord": "加入 ArtCraft Discord", + "Join the ArtCraft Discord…": "加入 ArtCraft Discord…", + "Keep in Local": "保留在本機", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "請放在空間充足的磁碟:每張智慧型預覽約 1 MB。縮圖快取仍保留在照片庫中。", + "Keyboard Shortcuts": "鍵盤快速鍵", + "Keyboard shortcuts": "鍵盤快速鍵", + "Keyword": "關鍵字", + "Keyword Painter": "關鍵字筆刷", + "Keyword Set": "關鍵字集", + "Keyword Sets": "關鍵字集", + "Keyword Suggestions": "關鍵字建議", + "Keyword to paint": "要塗上的關鍵字", + "Keywords": "關鍵字", + "Keywords (K)": "關鍵字(K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "新增的關鍵字會顯示於此;⌥1–⌥9 可套用。", + "Landscape": "風景", + "Language": "語言", + "Largest long edge the Detail view renders at; larger is sharper on big displays but slower.": "詳細檢視預覽的最長邊尺寸;較大的尺寸在大螢幕上更清晰,但速度較慢。", + "Last": "最後", + "Last view": "上次檢視", + "Lateral Chromatic Aberration": "橫向色差", + "Layer": "圖層", + "Layers": "圖層", + "Leave them": "保留這些照片", + "Lens": "鏡頭", + "Lens Corrections": "鏡頭校正", + "Level": "水平校正", + "Library": "照片庫", + "Library Info": "照片庫資訊", + "Library Originals": "照片庫原始檔案", + "Library Preferences": "照片庫偏好設定", + "Library State": "照片庫狀態", + "Light": "光線", + "LightCraft Default": "LightCraft 預設值", + "LightCraft Help": "LightCraft 輔助說明", + "LightCraft Preset": "LightCraft 預設集", + "LightCraft Presets": "LightCraft 預設集", + "LightCraft Website": "LightCraft 網站", + "LightCraft couldn't find where to keep your library.": "LightCraft 找不到儲存照片庫的位置。", + "LightCraft couldn't open the library at": "LightCraft 無法開啟此位置的照片庫:", + "LightCraft on GitHub": "GitHub 上的 LightCraft", + "LightCraft website": "LightCraft 網站", + "LightCraft — Second Window": "LightCraft — 第二視窗", + "Lights": "亮調", + "Limit file size (KB, 0 = off)": "檔案大小上限(KB,0 = 不限制)", + "Linear Gradient": "線性漸層", + "List Albums": "列出相簿", + "List Develop Controls": "列出顯像控制項", + "List History": "列出歷史紀錄", + "List Presets": "列出預設集", + "List Profiles": "列出描述檔", + "List this folder in Local from now on": "將此資料夾永久列在本機", + "Local": "本機", + "Local folders": "本機資料夾", + "Local library — no cloud account needed": "本機照片庫,無需雲端帳號", + "Locate Missing File…": "尋找遺失的檔案…", + "Locate Photo": "尋找照片", + "Location": "位置", + "Long Edge": "長邊", + "Looking for photos…": "正在尋找照片…", + "Lossless": "無損", + "Low": "低", + "Luminance": "明度", + "Luminance Range": "明度範圍", + "Lush Greens": "濃郁綠意", + "Magenta": "洋紅色", + "Magenta Hue": "洋紅色色相", + "Magenta Luminance": "洋紅色明度", + "Magenta Saturation": "洋紅色飽和度", + "Make Candidate the Select": "將候選照片設為選定照片", + "Manual": "手動", + "Marquee zoom": "選取範圍縮放", + "Masking": "遮色片", + "Masking (M)": "遮色片(M)", + "Masks": "遮色片", + "Match": "符合", + "Match Total Exposures": "符合總曝光量", + "Matte": "霧面", + "Matte Soft": "柔和霧面", + "Med": "中", + "Medium Contrast": "中等對比", + "Megapixels": "百萬像素", + "Memory Budget": "記憶體預算", + "Memory Usage": "記憶體使用量", + "Memory for caches": "快取記憶體", + "Menu": "選單", + "Merge": "合併", + "Merge Keywords": "合併關鍵字", + "Merge into…": "合併至…", + "Merging…": "正在合併…", + "Metadata": "中繼資料", + "Metadata Preset": "中繼資料預設集", + "Metadata Presets": "中繼資料預設集", + "Metadata preset": "中繼資料預設集", + "Midpoint": "中點", + "Midtones": "中間調", + "Midtones Hue": "中間調色相", + "Midtones Luminance": "中間調明度", + "Midtones Saturation": "中間調飽和度", + "Midtones split": "中間調分界", + "Minimize": "最小化", + "Missing Photos": "遺失的照片", + "Misty Morning": "薄霧清晨", + "Mode": "模式", + "Modified Date": "修改日期", + "Monday": "星期一", + "Mono Red Filter": "黑白紅色濾鏡", + "Mono Rich": "濃郁黑白", + "Mono Soft": "柔和黑白", + "Monochrome": "單色", + "Month": "月", + "Moody": "氛圍風格", + "Moody Portrait": "氛圍人像", + "More": "更多", + "More preset options": "更多預設集選項", + "Move": "移動", + "Move Album": "移動相簿", + "Move Down in Stack": "在堆疊中下移", + "Move Folder": "移動資料夾", + "Move Folder To…": "將資料夾移至…", + "Move Mask": "移動遮色片", + "Move Preset to Group": "將預設集移至群組", + "Move Up in Stack": "在堆疊中上移", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "移動檔案(至照片庫的 Originals/ 或指定資料夾),並從來源移除", + "Move them": "移動這些照片", + "Move to": "移至", + "Move to Group": "移至群組", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "移動:檔案會移至下方資料夾;每份原始檔案及 XMP 附屬檔案只會在複本通過驗證後才從來源移除。重複及失敗的檔案會留在原處。", + "Muted": "低彩度", + "Muted Film": "低彩度底片", + "My Photos": "我的照片", + "My Photos Panel": "我的照片面板", + "Name": "名稱", + "Named": "已命名", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "名稱會顯示於標籤選單、篩選列與資訊面板,並寫入 XMP。留空則使用色彩名稱。", + "Navigator": "導覽器", + "Needs Work": "需要調整", + "Neon Dusk": "霓虹暮色", + "Neon Night": "霓虹之夜", + "Neutral": "中性", + "Never": "永不", + "New": "新增", + "New Album": "新增相簿", + "New Album…": "新增相簿…", + "New Folder…": "新增資料夾…", + "New Group…": "新增群組…", + "New Smart Album": "新增智慧型相簿", + "New Smart Album from Filter…": "依篩選條件新增智慧型相簿…", + "New Smart Album…": "新增智慧型相簿…", + "New album": "新增相簿", + "New album…": "新增相簿…", + "New blank PDF": "新增空白 PDF", + "New name": "新名稱", + "New…": "新增…", + "Next Photo": "下一張照片", + "Night Blue": "夜色藍調", + "No": "否", + "No Camera or Card Found": "找不到相機或記憶卡", + "No Date Groups": "沒有日期群組", + "No Stars": "無星等", + "No Tool": "無工具", + "No automatic versions.": "沒有自動版本。", + "No camera": "無相機", + "No date": "無日期", + "No dust spots found": "找不到灰塵斑點", + "No edits yet.": "尚未編輯。", + "No exposure information": "沒有曝光資訊", + "No filter presets yet": "尚無篩選預設集", + "No lens data embedded in this file (DNG lens opcodes only).": "此檔案未內嵌鏡頭資料(僅支援 DNG 鏡頭操作碼)。", + "No masks yet. Choose a mask type above.": "尚無遮色片。請在上方選擇遮色片類型。", + "No matching photos": "沒有符合的照片", + "No metadata found": "找不到中繼資料", + "No new presets": "沒有新預設集", + "No photo selected": "尚未選取照片", + "No photos": "沒有照片", + "No photos found": "找不到照片", + "No photos in this folder": "此資料夾沒有照片", + "No photos match the active filters": "沒有照片符合目前的篩選條件", + "No photos match the filters (View → Clear Filters)": "沒有照片符合篩選條件(檢視 → 清除篩選條件)", + "No raw photos yet: cameras appear here once their photos are in the library.": "尚無 RAW 照片:將照片加入照片庫後,相機會顯示於此。", + "No reference photo": "沒有參考照片", + "No usable GPU found: rendering on the CPU": "找不到可用的 GPU:使用 CPU 算繪", + "No versions yet. Create one to keep this look.": "尚無版本。建立版本即可保留目前的效果。", + "Noise": "雜訊", + "Noise Reduction": "雜訊抑制", + "Non-raw photos": "非 RAW 照片", + "None": "無", + "Nothing to compare": "沒有可比較的照片", + "Nothing to survey": "沒有可總覽的照片", + "Nudge Develop Value": "微調顯像數值", + "OK": "確定", + "Object": "物件", + "Off": "關閉", + "Offset X": "X 位移", + "Offset Y": "Y 位移", + "Opacity": "不透明度", + "Opacity (%)": "不透明度(%)", + "Open Library": "開啟照片庫", + "Open Library…": "開啟照片庫…", + "Open in": "開啟於", + "Open in Detail": "在詳細檢視中開啟", + "Open the place in OpenStreetMap": "在 OpenStreetMap 中開啟此位置", + "Open…": "開啟…", + "Optics": "光學", + "Optimize Library": "最佳化照片庫", + "Optional name": "名稱(選填)", + "Options": "選項", + "Orange": "橙色", + "Orange Hue": "橙色色相", + "Orange Luminance": "橙色明度", + "Orange Saturation": "橙色飽和度", + "Orientation": "方向", + "Original": "原始", + "Original + Settings": "原始檔與設定", + "Original files": "原始檔案", + "Original offline · editing the smart preview": "原始檔案離線,正在編輯智慧型預覽", + "Original offline · no smart preview": "原始檔案離線,無智慧型預覽", + "Originals": "原始檔案", + "Other images (JPEG, PNG, TIFF, HEIC…)": "其他影像(JPEG、PNG、TIFF、HEIC…)", + "Overlay": "疊加", + "Overwrite": "覆寫", + "Pages": "頁面", + "Paint": "繪製", + "Paint only areas like the one under the brush": "只塗繪與筆刷下方相似的區域", + "Paint over a distraction on the photo to remove it.": "在照片中不需要的物件上塗繪以將其移除。", + "Paint overlay": "塗繪疊加", + "Panorama Merge": "全景合併", + "Panorama with Last Settings": "使用上次設定合併全景", + "Panorama…": "全景…", + "Parametric curve": "參數曲線", + "Paste": "貼上", + "Paste Edit Settings": "貼上編輯設定", + "Paste Metadata": "貼上中繼資料", + "Paste Selected Settings": "貼上選取的設定", + "Paste Selected Settings…": "貼上選取的設定…", + "Paste Settings from Previous": "貼上前一張照片的設定", + "Paste pages": "貼上頁面", + "Pastel Haze": "粉彩薄霧", + "Percent": "百分比", + "Percentage": "百分比", + "Performance": "效能", + "Perspective": "透視", + "Pet Eye": "寵物眼睛", + "Pet Eye Catchlight": "寵物眼神光", + "Photo": "照片", + "Photo Grid": "照片格狀檢視", + "Photo Grid (G)": "照片格狀檢視(G)", + "Photo Merge": "照片合併", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "「照片 → 在外部編輯器中編輯」(⇧⌘E)會算繪 16 位元 TIFF 副本,與原始照片堆疊後在此開啟(macOS 填應用程式名稱,其他平台填程式路徑)。", + "Photos": "照片", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "曾在本機瀏覽、但未加入或變更的照片,在其資料夾超過此期間未瀏覽時會移出目錄(開啟照片庫時檢驗)。檔案仍保留在磁碟上,再次瀏覽資料夾即可重新顯示。", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "此資料夾中新出現的照片(連線拍攝、掃描器或同步程式)會在檔案完整後立即加入。", + "Pick": "精選", + "Pick a colour on the photo with the eyedropper.": "使用滴管在照片上選取色彩。", + "Pick the sharpest photo of each burst": "選取每組連拍中最清晰的照片", + "Picked": "已標記精選", + "Picks": "精選照片", + "Pixels": "像素", + "Point Color": "點色彩", + "Point Curve": "點曲線", + "Point Curve Presets": "點曲線預設集", + "Point curve": "點曲線", + "Portrait": "人像", + "Position": "位置", + "Preferences": "偏好設定", + "Preferences…": "偏好設定…", + "Preset": "預設集", + "Preset name": "預設集名稱", + "Presets": "預設集", + "Presets & Profiles": "預設集與描述檔", + "Presets (Shift+P)": "預設集(Shift+P)", + "Presets ▾": "預設集", + "Preview": "預覽", + "Preview Build Progress": "預覽建立進度", + "Preview only": "僅供預覽", + "Preview size": "預覽尺寸", + "Previews": "預覽", + "Previous Photo": "上一張照片", + "Print": "列印", + "Print…": "列印…", + "Profile": "描述檔", + "Profile Browser": "描述檔瀏覽器", + "Profile Distortion": "描述檔變形校正", + "Profile Menu": "描述檔選單", + "Profile Vignetting": "描述檔暗角校正", + "Profile:": "描述檔:", + "Profiles": "描述檔", + "Projection": "投影", + "Properties": "屬性", + "Public domain": "公眾領域", + "Pupil Size": "瞳孔尺寸", + "Purple": "紫色", + "Purple Amount": "紫色強度", + "Purple Hue": "紫色色相", + "Purple Hue High": "紫色色相上限", + "Purple Hue Low": "紫色色相下限", + "Purple Luminance": "紫色明度", + "Purple Saturation": "紫色飽和度", + "Put the locations you removed from Local back (no files change)": "恢復從本機移除的位置(不變更任何檔案)", + "Quality": "品質", + "Query Photos": "搜尋照片", + "Quick Collection": "快速集合", + "Quick Develop": "快速顯像", + "Quiet Green": "靜謐綠色", + "Quit": "結束", + "Quit LightCraft": "結束 LightCraft", + "Radial Gradient": "放射狀漸層", + "Radius": "半徑", + "Range": "範圍", + "Rating": "評分", + "Ratings & flags": "評分與旗標", + "Raw default": "RAW 預設值", + "Raw defaults": "RAW 預設值", + "Raw files": "RAW 檔案", + "Raw photos": "RAW 照片", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "將 RAW 照片轉為 DNG,並內嵌編輯設定。尺寸、色彩與輸出選項不適用。", + "Read Metadata from File": "從檔案讀取中繼資料", + "Read mode": "閱讀模式", + "Reading": "正在讀取", + "Reading folder…": "正在讀取資料夾…", + "Recently Added": "最近加入", + "Recently Deleted": "最近刪除", + "Red": "紅色", + "Red Eye": "紅眼", + "Red Hue": "紅色色相", + "Red Luminance": "紅色明度", + "Red Primary": "紅色原色", + "Red Saturation": "紅色飽和度", + "Red channel": "紅色通道", + "Red/Cyan Fringe": "紅/青色邊緣", + "Redo": "重做", + "Reference": "參考", + "Reference View": "參考檢視", + "Reference the files where they are": "參照檔案目前的位置", + "Refine Mask Edges": "調整遮色片邊緣", + "Refresh Source": "重新整理來源", + "Refresh Source (/)": "重新整理來源(/)", + "Reject": "排除", + "Reject below focus": "排除低於此焦點分數的照片", + "Rejected": "已排除", + "Rejects": "排除的照片", + "Reload from Disk": "從磁碟重新載入", + "Remove": "移除", + "Remove (H)": "移除(H)", + "Remove CA": "移除色差", + "Remove Chromatic Aberration": "移除色差", + "Remove from Album": "從相簿移除", + "Remove from Favorites": "從喜好項目移除", + "Remove from Local": "從本機移除", + "Remove from Photo": "從照片移除", + "Remove from Selected Photos": "從選取的照片移除", + "Remove from Stack": "從堆疊移除", + "Remove from survey": "從總覽移除", + "Remove location info": "移除位置資訊", + "Remove this filter": "移除此篩選條件", + "Remove this group": "移除此群組", + "Remove this rule": "移除此規則", + "Rename": "重新命名", + "Rename Album": "重新命名相簿", + "Rename Folder": "重新命名資料夾", + "Rename Folder…": "重新命名資料夾…", + "Rename Keyword": "重新命名關鍵字", + "Rename Keyword…": "重新命名關鍵字…", + "Rename Mask": "重新命名遮色片", + "Rename Photos": "重新命名照片", + "Rename Photos…": "重新命名照片…", + "Rename Preset": "重新命名預設集", + "Rename Preview": "重新命名預覽", + "Rename Template Tags": "重新命名範本標籤", + "Rename Version": "重新命名版本", + "Rename…": "重新命名…", + "Rendering": "算繪", + "Rendering…": "正在算繪…", + "Reset": "重設", + "Reset All Curves": "重設所有曲線", + "Reset Before to Import State": "將編輯前重設為匯入時狀態", + "Reset Control": "重設控制項", + "Reset Crop": "重設裁切", + "Reset Edits": "重設編輯", + "Reset Parametric Curve": "重設參數曲線", + "Reset Section": "重設區段", + "Reset Tone Curve": "重設色調曲線", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重設所有點曲線通道與參數曲線(按兩下通道可只重設該通道)", + "Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)", + "Resize to": "調整尺寸為", + "Resolution (ppi)": "解析度(ppi)", + "Restore": "還原", + "Restore Library from Backup…": "從備份還原照片庫…", + "Restore Version": "還原版本", + "Retouch": "修飾", + "Revert": "回復至上次儲存", + "Review Import": "檢視匯入", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "在照片上按右鍵 → 設為參考照片,再選擇「檢視 → 參考檢視」(Shift+R)", + "Right-click a preset to delete it": "在預設集上按右鍵即可刪除", + "Rights Usage Terms": "使用權利條款", + "Rotate": "旋轉", + "Rotate Crop Aspect": "旋轉裁切長寬比", + "Rotate Left": "向左旋轉", + "Rotate Right": "向右旋轉", + "Roughness": "粗糙度", + "Roundness": "圓度", + "Same as raw default": "與 RAW 預設值相同", + "Sample Mask Color": "取樣遮色片色彩", + "Sample a colour on the photo": "在照片上取樣色彩", + "Saturation": "飽和度", + "Saturation Range": "飽和度範圍", + "Saturday": "星期六", + "Save": "儲存", + "Save As…": "另存新檔…", + "Save Color Label Set": "儲存色彩標籤集", + "Save Current Filter as Preset…": "將目前篩選條件儲存為預設集…", + "Save Current Keywords as Set…": "將目前關鍵字儲存為關鍵字集…", + "Save Export Preset": "儲存匯出預設集", + "Save Filter Preset": "儲存篩選預設集", + "Save Keyword Set": "儲存關鍵字集", + "Save Metadata Preset": "儲存中繼資料預設集", + "Save Metadata Preset…": "儲存中繼資料預設集…", + "Save Metadata to File": "將中繼資料儲存至檔案", + "Save Point Curve Preset": "儲存點曲線預設集", + "Save Point Curve…": "儲存點曲線…", + "Save as Preset": "儲存為預設集", + "Save as Smart Album…": "儲存為智慧型相簿…", + "Save as set": "儲存為關鍵字集", + "Save as…": "另存新檔…", + "Save preset": "儲存預設集", + "Scale": "縮放", + "Scanning": "正在掃描", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "分數會保留在照片上:可依焦點與相似照片中的最佳照片篩選,或建立智慧型相簿。", + "Screen": "螢幕", + "Search Photos": "搜尋照片", + "Search…": "搜尋…", + "Seasons": "四季", + "Second Window": "第二視窗", + "Select": "選定照片", + "Select All": "全選", + "Select Luminance Range": "選取明度範圍", + "Select Mask": "選取遮色片", + "Select Photos": "選取照片", + "Select Spot": "選取斑點", + "Select a photo to edit": "選取要編輯的照片", + "Select by": "選取依據", + "Select by Flag, Rating or Label": "依旗標、評分或標籤選取", + "Select photos, then choose View → Survey (N)": "選取照片後,選擇「檢視 → 總覽」(N)", + "Select two photos, then choose View → Compare (Shift+C)": "選取兩張照片後,選擇「檢視 → 比較」(Shift+C)", + "Selected": "已選取", + "Selenium Tone": "硒調色", + "Send Feedback…": "傳送意見回饋…", + "Sepia Tone": "深褐色調", + "Services": "服務", + "Set Before": "設定編輯前", + "Set Color Label": "設定色彩標籤", + "Set Crop": "設定裁切", + "Set Develop Values": "設定顯像參數", + "Set Flag": "設定旗標", + "Set Mask Adjustments": "設定遮色片調整", + "Set Point Curve": "設定點曲線", + "Set Profile": "設定描述檔", + "Set Rating": "設定評分", + "Set as Album Cover": "設為相簿封面", + "Set as Before": "設為編輯前", + "Set as Reference Photo": "設為參考照片", + "Set as Target Album": "設為目標相簿", + "Set as Target Album (B adds to it)": "設為目標相簿(按 B 加入)", + "Set as Top of Stack": "設為堆疊頂端", + "Set the mix from the photo's colours": "依照片色彩設定混色", + "Settings": "設定", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "新 RAW 照片使用的初始設定。變更不會影響照片庫中已有的照片。", + "Settings to include": "要包含的設定", + "Settings…": "設定…", + "Shade": "陰影處", + "Shadow": "陰影", + "Shadows": "陰影", + "Shadows Hue": "陰影色相", + "Shadows Luminance": "陰影明度", + "Shadows Saturation": "陰影飽和度", + "Shadows Tint": "陰影色調", + "Shadows split": "陰影分界", + "Sharpen": "銳利化", + "Sharpening": "銳利化", + "Sharpness": "銳利度", + "Short Edge": "短邊", + "Show All": "全部顯示", + "Show Clipping": "顯示溢色", + "Show Deghost Overlay": "顯示去除鬼影疊加", + "Show Hidden Local Locations": "顯示隱藏的本機位置", + "Show Luminance Map": "顯示明度圖", + "Show Mask": "顯示遮色片", + "Show Mask Overlay": "顯示遮色片疊加", + "Show Mask Pins": "顯示遮色片控制點", + "Show Original": "顯示原始照片", + "Show Photo Counts": "顯示照片數量", + "Show Photos with Keyword": "顯示含此關鍵字的照片", + "Show Pins": "顯示控制點", + "Show Source": "顯示來源", + "Show Thumbnails": "顯示縮圖", + "Show destination gamut warning": "顯示目標色域警告", + "Show display gamut warning": "顯示螢幕色域警告", + "Show file names": "顯示檔案名稱", + "Show in Finder": "在 Finder 中顯示", + "Show on Map": "在地圖上顯示", + "Show overlay (O)": "顯示疊加(O)", + "Show ratings, flags and edit badges": "顯示評分、旗標與編輯標記", + "Show the Navigator while zoomed in": "放大時顯示導覽器", + "Show the template tags; click one to insert it": "顯示範本標籤;點擊即可插入", + "Show/Hide Mask": "顯示/隱藏遮色片", + "Show/hide My Photos panel": "顯示/隱藏我的照片面板", + "Shutter speed": "快門速度", + "Side panels": "側邊面板", + "Sidecar names": "附屬檔案名稱", + "Size": "尺寸", + "Size (% of short edge)": "尺寸(短邊百分比)", + "Size limit": "大小上限", + "Skip": "略過", + "Sky": "天空", + "Slideshow": "幻燈片播放", + "Smart Album": "智慧型相簿", + "Smart Album Rule Fields": "智慧型相簿規則欄位", + "Smart Preview Status": "智慧型預覽狀態", + "Smart Previews Location": "智慧型預覽位置", + "Smart previews": "智慧型預覽", + "Smoothness": "平滑度", + "Soft B&W": "柔和黑白", + "Soft Pastel": "柔和粉彩", + "Soft Proofing": "軟式校樣", + "Soft Skin": "柔和膚質", + "Sort": "排序", + "Sort by": "排序依據", + "Source": "來源", + "Source code on GitHub": "GitHub 上的原始碼", + "Spherical": "球面", + "Spiral": "螺旋", + "Split Stack": "分割堆疊", + "Spring Fresh": "清新春日", + "Square Grid": "方格檢視", + "Square Grid (G toggles)": "方格檢視(按 G 切換)", + "Square Grid: show file names and formats": "方格檢視:顯示檔案名稱與格式", + "Stack": "堆疊", + "Stack photos taken within this time of each other:": "將拍攝時間間隔在此範圍內的照片堆疊:", + "Stacks": "堆疊", + "Standard": "標準", + "Start at": "起始值", + "Start number": "起始編號", + "Startup": "啟動", + "State / Province": "州/省", + "Stop": "停止", + "Stop Building Previews": "停止建立預覽", + "Stop Using as Target Album": "停止用作目標相簿", + "Stopping…": "正在停止…", + "Straighten": "拉直", + "Straighten Tool": "拉直工具", + "Strong Contrast": "強烈對比", + "Style": "樣式", + "Subfolder": "子資料夾", + "Subfolder name": "子資料夾名稱", + "Subject": "主體", + "Subtract": "減去", + "Suggestions": "建議", + "Summer Haze": "夏日薄霧", + "Sunday": "星期日", + "Survey": "總覽", + "Survey (N)": "總覽(N)", + "Swap": "交換", + "Swap Aspect (X)": "交換長寬比(X)", + "Swap Before and After Settings": "交換編輯前與編輯後設定", + "Swap Compare Photos": "交換比較照片", + "Switch light / dark theme": "切換淺色/深色主題", + "Sync Settings": "同步設定", + "System Info": "系統資訊", + "System Info…": "系統資訊…", + "System default": "系統預設值", + "Tags": "標籤", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "標籤:請參閱範本旁的「標籤」。檔案及其 XMP 附屬檔案會在磁碟上重新命名;遇到同名檔案會加上 -1、-2…", + "Take a snapshot": "擷取快照", + "Targeted Adjustment": "目標調整", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "目標調整:在照片上向上/下拖曳,調整該位置的色彩", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "目標調整:在照片上向上/下拖曳,調整該位置的曲線", + "Teal & Amber": "青綠與琥珀", + "Teal & Orange": "青綠與橙色", + "Temp": "色溫", + "Temperature": "色溫", + "Template": "範本", + "Temporary session — your library isn't open, and nothing you do here is saved.": "暫存工作階段:照片庫未開啟,此處的操作不會儲存。", + "Text": "文字", + "Text direction": "文字方向", + "Texture": "紋理", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "保留未變更的原始檔案,每個檔案附有記錄編輯設定的 XMP 附屬檔案。", + "The photo has these settings": "此照片具有這些設定", + "Theme": "主題", + "They can be restored from Recently Deleted until it is emptied.": "清空「最近刪除」之前,可以從中還原照片。", + "Thirds": "三分法", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "此資料夾無法使用(磁碟可能已中斷連線)。恢復連線或選擇其他資料夾之前,不會建立或使用智慧型預覽。", + "This photo can't be opened": "無法開啟此照片", + "Threshold": "臨界值", + "Thumbnail cache": "縮圖快取", + "Thursday": "星期四", + "Tint": "色調", + "Title": "標題", + "Toggle Keyword from Set": "切換關鍵字集中的關鍵字", + "Toggle Section": "切換區段", + "Toggle Zoom": "切換縮放", + "Tone Curve": "色調曲線", + "Tools": "工具", + "Top Level": "最上層", + "Transfer": "傳輸", + "Treatment": "處理方式", + "Triangle": "三角形", + "Try Again": "再試一次", + "Tuesday": "星期二", + "Tungsten": "鎢絲燈", + "Turn Off": "關閉", + "Turn on Include subfolders, or pick another folder under Local": "啟用「包含子資料夾」,或在本機中選擇其他資料夾", + "Type": "類型", + "Uncheck All": "全部取消勾選", + "Undo": "復原", + "Unedited": "未編輯", + "Unflag": "移除旗標", + "Unflagged": "無旗標", + "Ungroup Stack": "取消堆疊", + "Unknown Date": "未知日期", + "Unrated": "未評分", + "Unreadable": "無法讀取", + "Update": "更新", + "Update Mask Shape": "更新遮色片形狀", + "Update Preset with Current Settings": "以目前設定更新預設集", + "Update Rules from Current Filter": "依目前篩選條件更新規則", + "Update Version": "更新版本", + "Update Version with Current Settings": "以目前設定更新版本", + "Update with Current Settings": "以目前設定更新", + "Upright": "透視校正", + "Urban": "城市", + "Use Keyword Set": "使用關鍵字集", + "Use Library Folder": "使用照片庫資料夾", + "Use camera-specific defaults": "使用個別相機的預設值", + "Use the GPU for rendering": "使用 GPU 算繪", + "User Presets": "使用者預設集", + "Value": "數值", + "Variance": "變異數", + "Version": "版本", + "Version name": "版本名稱", + "Versions": "版本", + "Versions (Shift+V)": "版本(Shift+V)", + "Vertical": "垂直", + "Vertical text": "垂直文字", + "Vibrance": "自然飽和度", + "Videos": "影片", + "View": "檢視", + "Vignette": "暗角", + "Vignetting": "暗角", + "Vintage": "復古", + "Virtual Copy": "虛擬副本", + "Visible": "可見照片", + "Visualize Spots": "顯示斑點", + "Visualize Spots (A)": "顯示斑點(A)", + "Visualize range": "顯示範圍", + "Vivid": "鮮豔", + "Vivid Pop": "鮮明色彩", + "Warm Film": "暖調底片", + "Warm Glow": "暖光", + "Warm Print": "暖調沖印", + "Warm Table": "溫暖餐桌", + "Watched folder": "監看的資料夾", + "Watermark": "浮水印", + "Wednesday": "星期三", + "What's New": "新增功能", + "When rated or hovered": "評分或游標停留時", + "White Balance": "白平衡", + "White Balance Selector": "白平衡選取器", + "White Balance Selector (W)": "白平衡選取器(W)", + "Whites": "白色色階", + "Width": "寬度", + "Width (% of photo)": "寬度(照片百分比)", + "Width (px)": "寬度(像素)", + "Width × Height": "寬度 × 高度", + "Window": "視窗", + "Winter Blue": "冬日藍調", + "XMP Sidecar Preferences": "XMP 附屬檔案偏好設定", + "XMP sidecars": "XMP 附屬檔案", + "Year": "年", + "Yellow": "黃色", + "Yellow Hue": "黃色色相", + "Yellow Luminance": "黃色明度", + "Yellow Saturation": "黃色飽和度", + "Yes": "是", + "Your library couldn't be opened": "無法開啟你的照片庫", + "Zoom": "縮放", + "Zoom 100%": "縮放至 100%", + "Zoom In": "放大", + "Zoom Out": "縮小", + "Zoom to Fit": "縮放至符合視窗", + "Zoom to page level": "符合頁面大小", + "and": "且", + "comma, separated": "以逗號分隔", + "memory only": "僅記憶體", + "none": "無", + "not available here": "此處無法使用", + "of the following rules:": "下列規則:", + "this photo": "這張照片", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "In Recently Deleted": "在「最近刪除」中" +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d38980654..b592557a7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -267,6 +267,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest u.dialog = app.ui.dialog.clone(); u.status = app.ui.status.clone(); app.ui = u; + crate::i18n::set_language(app.ui.language); ctx.request_repaint(); ok(serde_json::to_value(&app.ui).unwrap_or_default()) } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index c208ecb6e..3fc906449 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -118,6 +118,7 @@ macro_rules! language_table { language_table! { En, "en"; ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json"); + ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json"); Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json"); } @@ -183,6 +184,93 @@ fn catalog_verbatim(language: Locale) -> BTreeMap { // `locales/*-formats.json`. include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); +/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays +/// exactly as its owner wrote it. +pub fn builtin_label(source: &str, builtin: bool) -> &str { + if builtin { tr(source) } else { source } +} + +/// An Activity (history) step for display. The step's stored label stays English; generated steps +/// are translated around the names they carry, and a user preset's name is kept verbatim. +pub fn history_label(source: &str, presets: &[lightcraft_develop::Preset]) -> String { + if let Some(name) = source.strip_prefix("Preset: ") { + // A user preset may share a stock preset's name: then the step could be either, so it + // keeps the name as written. + let builtin = presets.iter().any(|p| p.builtin && p.name == name) && !presets.iter().any(|p| !p.builtin && p.name == name); + return tr_format!("Preset: {name}", name = builtin_label(name, builtin)); + } + if let Some(name) = source.strip_prefix("Reset ") { + return tr_format!("Reset {name}", name = tr(name)); + } + if let Some(name) = source.strip_prefix("Quick Develop: ") { + return tr_format!("Quick Develop: {name}", name = tr(name)); + } + tr(source).to_string() +} + +/// A library source's heading (All Photos, Recently Deleted…) in the UI language; an album's +/// name is the user's and stays verbatim. +pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcraft_catalog::Catalog) -> String { + let label = source.label(catalog); + if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() } +} + +/// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full +/// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the +/// catalog's own wording; every other language formats it with its `*-formats.json` date +/// patterns and weekday names. The grouping keys themselves never change. +pub fn date_group_label(key: &str, short: bool) -> String { + let label = lightcraft_catalog::dates::group_label(key); + if language() == Locale::En { + return if short { + match key.len() { + // "September 2026" → "September" + 7 => label.split(' ').next().unwrap_or(&label).to_string(), + // "Sunday, 20 September 2026" → "Sunday, 20" + 10 => label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(), + _ => label, + } + } else { + label + }; + } + let Some(year) = key.get(..4).and_then(|y| y.parse::().ok()) else { return tr(&label).to_string() }; + if key.len() == 4 { + return tr_format!("{year}", year = year); + } + let Some(month) = key.get(5..7).and_then(|m| m.parse::().ok()).filter(|m| (1..=12).contains(m)) else { + return tr(&label).to_string(); + }; + if key.len() == 7 { + return if short { tr_format!("{month}", month = month) } else { tr_format!("{month} {year}", month = month, year = year) }; + } + if key.len() == 10 + && let Some(weekday) = lightcraft_catalog::dates::weekday(key) + && let Some(day) = key.get(8..10).and_then(|d| d.parse::().ok()) + { + let weekday = tr(weekday); + return if short { + tr_format!("{weekday}, {day}", weekday = weekday, day = day) + } else { + tr_format!("{weekday}, {day} {month} {year}", weekday = weekday, day = day, month = month, year = year) + }; + } + tr(&label).to_string() +} + +/// A capture time for display in the UI language (English: "March 30, 2022 at 10:11:11 PM"); +/// the metadata itself is never rewritten, and a value that doesn't parse is shown as it is. +pub fn display_time(iso: &str) -> String { + if language() == Locale::En || lightcraft_catalog::dates::normalize_iso(iso).is_none() { + return lightcraft_catalog::dates::display_time(iso); + } + let date = date_group_label(iso.get(..10).unwrap_or(iso), false); + match iso.get(11..).filter(|time| !time.is_empty()) { + Some(time) => format!("{date} {time}"), + None => date, + } +} + #[cfg(test)] mod tests { use super::*; @@ -205,10 +293,11 @@ mod tests { } } - /// The catalogs of the translations agree: same keys, same format placeholders. A source string - /// added to one language but forgotten in another fails here. + /// Where two catalogs translate the same text they agree on its placeholders. Catalogs may cover + /// different sets of messages (a message a language lacks shows in English): the gaps are + /// reported, not failed, so a translation can grow at its own pace. #[test] - fn catalogs_agree_on_keys_and_placeholders() { + fn catalogs_agree_on_placeholders_and_report_gaps() { let fields = |text: &str| { let mut out: Vec = Vec::new(); let mut rest = text; @@ -221,18 +310,15 @@ mod tests { out }; let translated: Vec = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect(); - let Some((first, rest)) = translated.split_first() else { return }; - for language in rest { - let (a, b) = (first.catalog(), language.catalog()); - for (key, value) in a { - let Some(other) = b.get(key) else { - panic!("{language:?} is missing a translation for {key:?}"); - }; - assert_eq!(fields(key), fields(value), "{first:?}: {key:?} -> {value:?}"); - assert_eq!(fields(key), fields(other), "{language:?}: {key:?} -> {other:?}"); + let all: std::collections::BTreeSet<&String> = translated.iter().flat_map(|language| language.catalog().keys()).collect(); + for language in &translated { + let catalog = language.catalog(); + for (key, value) in catalog { + assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}"); } - for key in b.keys() { - assert!(a.contains_key(key), "{language:?} translates {key:?}, which {first:?} does not"); + let missing: Vec<&&String> = all.iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !missing.is_empty() { + eprintln!("{} lacks {} message(s) another language translates (shown in English): {missing:?}", language.code(), missing.len()); } } } @@ -245,6 +331,12 @@ mod tests { assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans)); assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans)); assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans)); + assert_eq!(Locale::parse_tag("zh-hant"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant)); + assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant)); assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans)); assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja)); assert_eq!(Locale::parse_tag("de"), None); @@ -313,7 +405,7 @@ mod tests { // The faces this language's script needs; a language the craft-fonts input does not // cover (a translation added ahead of its font) is reported, not failed. let mut faces: Vec<&str> = - lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.covers(language.script())).map(|font| font.family).collect(); + lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); faces.dedup(); if faces.is_empty() { eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); @@ -360,8 +452,12 @@ mod tests { /// The language menu covers every language, and a language's own command selects it. #[test] fn language_commands_cover_every_language() { - let commands = - [("app.language.english", Locale::En), ("app.language.simplifiedChinese", Locale::ZhHans), ("app.language.japanese", Locale::Ja)]; + let commands = [ + ("app.language.traditionalChinese", Locale::ZhHant), + ("app.language.english", Locale::En), + ("app.language.simplifiedChinese", Locale::ZhHans), + ("app.language.japanese", Locale::Ja), + ]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); for (id, language) in commands { @@ -479,9 +575,9 @@ mod tests { /// family, Monospace included. #[test] fn craft_fonts_render_cjk_in_the_ui() { - let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字")]; + let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; for (language, sample) in samples { - if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.covers(language.script())) { + if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } @@ -531,4 +627,104 @@ mod tests { } set_language(Locale::En); } + + /// Traditional Chinese through the menu command and the control channel, persisted, with + /// localised headings and dates; ids and user text are untouched. + #[test] + fn traditional_chinese_switches_through_menu_and_control_and_persists() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::ZhHant); + // Applied at once, not on the next frame. + assert_eq!(tr("File"), "檔案"); + assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片"); + assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); + assert_eq!(date_group_label("2026-09-20", true), "20日 星期日"); + assert_eq!(date_group_label("2026-09", false), "2026年9月"); + assert_eq!(date_group_label("2026-09", true), "9月"); + assert_eq!(date_group_label("2026", false), "2026年"); + assert_eq!(date_group_label("", false), "未知日期"); + assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05"); + assert_eq!(display_time("a-user-value"), "a-user-value"); + assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true)); + assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false)); + assert_eq!(tr("my-photo.jpg"), "my-photo.jpg"); + for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] { + assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color"); + } + assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩"); + assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片"); + assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"zh-hant""#), "{saved}"); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::ZhHant); + let ctx = egui::Context::default(); + app.ui.language = Locale::En; + for code in ["zh-hant", "zh-Hant", "zh-TW", "zh_TW", "zh-HK"] { + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; + assert_eq!(reply["ok"], true, "{code}"); + assert_eq!(app.ui.language, Locale::ZhHant, "{code}"); + assert_eq!(language(), Locale::ZhHant, "{code} applies at once"); + } + let (req, _) = ControlRequest::new("ui.set", serde_json::json!({"language": "xx"})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected a reply") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.ui.language, Locale::ZhHant); + let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::>(); + let chinese_ids = ids(&app); + app.run("app.language.english", serde_json::json!({})).unwrap(); + assert_eq!(tr("File"), "File"); + assert_eq!(chinese_ids, ids(&app)); + } + + /// Dates follow every language's own patterns; English keeps the catalog's wording. + #[test] + fn date_headings_in_every_language() { + set_language(Locale::En); + assert_eq!(date_group_label("2026-09-20", false), "Sunday, 20 September 2026"); + assert_eq!(date_group_label("2026-09-20", true), "Sunday, 20"); + assert_eq!(date_group_label("2026-09", true), "September"); + assert_eq!(display_time("2026-09-20T16:04:05"), lightcraft_catalog::dates::display_time("2026-09-20T16:04:05")); + set_language(Locale::Ja); + assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日(日曜日)"); + assert_eq!(date_group_label("2026-09", true), "9月"); + set_language(Locale::ZhHans); + assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日"); + for language in Locale::ALL { + set_language(*language); + // A key that is not a date never panics and never shows a raw pattern. + for key in ["", "abcd", "2026-xx", "2026-13", "2026-09-xx", "2026-09"] { + assert!(!date_group_label(key, false).contains('{'), "{language:?}: {key:?}"); + assert!(!date_group_label(key, true).contains('{'), "{language:?}: {key:?}"); + } + } + set_language(Locale::En); + } + + /// Stock presets and panel headers are translated; a user preset keeps its name, also when it + /// shares a stock preset's name. + #[test] + fn traditional_chinese_presets_and_panel_headers_are_painted() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.presets = true; + let builtin = app.session.presets.iter().find(|p| p.name == "Warm Glow").unwrap().clone(); + app.session.presets.push(lightcraft_develop::Preset { id: "user.test".into(), builtin: false, ..builtin }); + for (panel, title) in [(crate::state::RightPanel::Activity, "歷史紀錄"), (crate::state::RightPanel::Versions, "版本")] { + app.ui.right = panel; + let text = painted_text(&ctx, &mut app, Locale::ZhHant); + assert!(text.contains(title), "{text}"); + assert!(!text.contains("History") && !text.contains("Versions"), "{text}"); + assert!(text.contains("暖光"), "stock preset: {text}"); + assert!(text.contains("Warm Glow"), "user preset: {text}"); + assert!(text.contains("色彩"), "stock group: {text}"); + } + assert_eq!(history_label("Exposure", &app.session.presets), "曝光"); + assert_eq!(history_label("Preset: Warm Film", &app.session.presets), "預設集:暖調底片"); + assert_eq!(history_label("Preset: Warm Glow", &app.session.presets), "預設集:Warm Glow"); + set_language(Locale::En); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9f63e1088..1c57d6c85 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -15,6 +15,7 @@ pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"), ("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"), + ("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"), ("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"), ]; @@ -30,6 +31,7 @@ pub fn language_from_command(id: &str) -> Option { match id { "app.language.english" => Some(crate::i18n::Locale::En), "app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans), + "app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant), "app.language.japanese" => Some(crate::i18n::Locale::Ja), _ => None, } @@ -231,6 +233,9 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { if let Some(language) = language_from_command(id) { app.ui.language = language; + // Immediately, not on the next frame: the reply and anything else run this frame + // (menus rebuilt from it, toasts) are already in the new language. + crate::i18n::set_language(app.ui.language); return Some(Ok(json!(app.ui.language))); } let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 1ea18d0ed..a9ecdcfe4 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -466,7 +466,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.separator(); } builtin = b; - if ui.selectable_label(false, &p.name).clicked() { + if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() { chosen = Some(p.name); } } @@ -701,7 +701,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { opts } => crate::import::body(app, ui, opts), Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab), Dialog::ConfirmDelete { count } => { - let what = if *count == 1 { "this photo".to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; + let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); } @@ -710,7 +710,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits())); ui.add_space(10.0); - let discord = egui::Button::new(egui::RichText::new("Join the ArtCraft Discord").font(t.semibold(15.0)).color(egui::Color32::WHITE)) + let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE)) .fill(t.accent) .min_size(egui::vec2(260.0, 34.0)); let r = ui.add(discord).on_hover_text(crate::links::DISCORD); @@ -724,7 +724,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ("Source code on GitHub", crate::links::GITHUB), ("ArtCraft — more creative apps", crate::links::WEBSITE), ] { - let r = ui.link(label).on_hover_text(url); + let r = ui.link(crate::i18n::tr(label)).on_hover_text(url); if r.clicked() { let _ = crate::links::open(app, url); } @@ -775,7 +775,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { opts } => { let n = opts.selected_paths().len(); let verb = if opts.copy && opts.move_files { "Move" } else { "Import" }; - add_label = format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }); + add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); add_label.as_str() } Dialog::Merge { .. } => "Merge", diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ff9d9a261..cddb984e1 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -140,8 +140,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim)); let name = lightcraft_engine::presets::profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); - let r = - crate::widgets::dropdown(ui, "profile", if name == "Color" { crate::i18n::tr("Color") } else { name }, t.font(15.0), t.text_label); + let r = crate::widgets::dropdown(ui, "profile", crate::i18n::tr(name), t.font(15.0), t.text_label); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button(ui, "profileBrowser", Icon::ProfileGrid, vec2(28.0, 28.0), false, true, "Browse Profiles").clicked() { @@ -356,7 +355,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) key: &str, p: &'static lightcraft_engine::presets::ProfileInfo, pick: &mut (Option<&'static str>, Option<&'static str>)| { - let r = ui.selectable_label(p.id == cur, p.name); + let r = ui.selectable_label(p.id == cur, crate::i18n::tr(p.name)); register(ui.ctx(), format!("profileMenu:{key}:{}", p.id), r.rect); if r.clicked() { pick.0 = Some(p.id); @@ -366,7 +365,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } }; let heading = |ui: &mut egui::Ui, s: &str| { - ui.label(egui::RichText::new(s).font(t.semibold(11.5)).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr(s)).font(t.semibold(11.5)).color(t.text_dim)); }; for (title, key, ids) in [("Favorites", "fav", app.session.profile_favorites.clone()), ("Recent", "recent", app.session.profile_recent.clone())] { let list: Vec<_> = ids.iter().filter_map(|id| profile(id)).collect(); @@ -380,7 +379,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.separator(); } for g in profile_groups() { - let r = ui.menu_button(g, |ui| { + let r = ui.menu_button(crate::i18n::tr(g), |ui| { ui.set_min_width(170.0); for p in PROFILES.iter().filter(|p| p.group == g) { item(ui, "group", p, &mut pick); @@ -392,9 +391,9 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) if let Some(p) = profile(cur) { let fav = app.session.profile_favorites.iter().any(|f| f == p.id); let label = if fav { - crate::i18n::tr_format!("Remove “{}” from Favorites", p.name) + crate::i18n::tr_format!("Remove “{}” from Favorites", crate::i18n::tr(p.name)) } else { - crate::i18n::tr_format!("Add “{}” to Favorites", p.name) + crate::i18n::tr_format!("Add “{}” to Favorites", crate::i18n::tr(p.name)) }; let r = ui.button(label); register(ui.ctx(), "profileMenu:toggleFavorite", r.rect); @@ -414,7 +413,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) let mut s = d.clone(); s.profile.id = p.id.to_string(); s.profile.amount = 100.0; - app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s }); + app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s }); } if let Some(id) = pick.0 { let _ = app.run("develop.profile", json!({"id": id})); @@ -811,7 +810,9 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.horizontal(|ui| { ui.label(RichText::new(crate::i18n::tr("Point Curve")).font(t.font(12.0)).color(t.text_label)); let current = matching_preset(&app.session, &d.curve); - let r = crate::widgets::dropdown(ui, "curvePreset", current.as_deref().unwrap_or("Custom"), t.font(12.0), t.text); + let builtin = current.as_ref().is_none_or(|name| all_presets(&app.session).iter().any(|p| p.builtin && &p.name == name)); + let label = crate::i18n::builtin_label(current.as_deref().unwrap_or("Custom"), builtin); + let r = crate::widgets::dropdown(ui, "curvePreset", label, t.font(12.0), t.text); egui::Popup::menu(&r).show(|ui| { ui.set_min_width(180.0); let presets = all_presets(&app.session); @@ -821,7 +822,7 @@ fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) user_seen = true; ui.separator(); } - let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), &p.name); + let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), crate::i18n::builtin_label(&p.name, p.builtin)); register(ui.ctx(), format!("curvePreset:{}", p.name), r.rect); if r.clicked() { let _ = app.run("curve.applyPreset", json!({"name": p.name})); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 1c5a9a352..e83fec8b4 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -150,7 +150,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { folder_header(app, ui, hr, &b, &ids, local, &cnt) } None => { - let title = app.session.source.label(&app.session.catalog); + let title = crate::i18n::source_label(app.session.source, &app.session.catalog); ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text); ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim); } @@ -164,7 +164,11 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !chips.is_empty() { let body = match total { Some(n) if n > 0 => { - crate::i18n::tr_format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog), n = n) + crate::i18n::tr_format!( + "{n} photos in {} are hidden by the filters above", + crate::i18n::source_label(app.session.source, &app.session.catalog), + n = n + ) } _ => "Remove a filter above, or choose Clear all".to_string(), }; @@ -409,7 +413,7 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: p.rect_filled(r, 0.0, t.canvas); p.hline(r.x_range(), r.bottom(), Stroke::new(1.0, t.divider)); } - let g = p.layout_no_wrap(run.label.clone(), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label }); + let g = p.layout_no_wrap(crate::i18n::date_group_label(&run.key, false), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label }); let x = r.left() + 8.0; let gw = g.size().x; p.galley(pos2(x, r.center().y - g.size().y / 2.0), g, t.text); @@ -520,7 +524,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square .collect(); if parts.is_empty() { "—".to_string() } else { parts.join(" · ") } } - "date" => photo.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "No date".into()), + "date" => photo.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| crate::i18n::tr("No date").into()), _ => photo.file_name.rsplit_once('.').map(|(n, _)| n.to_string()).unwrap_or(photo.file_name.clone()), }; p.text(pos2(r.left() + 8.0, r.top() + 12.0), Align2::LEFT_CENTER, name, t.font(10.5), t.text_dim); @@ -786,7 +790,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.separator(); ui.menu_button(crate::i18n::tr("Set Rating"), |ui| { for r in 0..=5 { - if ui.button(if r == 0 { "No Stars".to_string() } else { "★".repeat(r) }).clicked() { + if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() { let _ = app.run("photo.rate", json!({"rating": r})); } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 7d7037435..760eefde8 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -120,14 +120,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("By Date"), t.semibold(13.5), t.text_label); for g in app.caches.date_groups(&app.session.catalog).iter() { // year → month → day; a click filters by that prefix, the triangle opens a level - if date_row(app, ui, &g.year, &g.year, g.count, 0.0) { + if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) { for (m, n) in &g.months { - let label = lightcraft_catalog::dates::group_label(m).split(' ').next().unwrap_or(m).to_string(); + let label = crate::i18n::date_group_label(m, true); if date_row(app, ui, m, &label, *n, 16.0) { for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { - let label = lightcraft_catalog::dates::group_label(d); - // "Sunday, 20 September 2026" → "Sunday, 20" - let label = label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(); + let label = crate::i18n::date_group_label(d, true); date_row(app, ui, d, &label, *n, 32.0); } } diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 20a095e03..f60ddc5d6 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -85,6 +85,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { groups.entry(p.group.clone()).or_default().push(p.clone()); } for (g, items) in groups { + // A group holding stock presets is a stock group (shown in the UI language), even + // when user presets share its name; a group of user presets keeps its name. + let builtin = items.iter().any(|p| p.builtin); let open_id = egui::Id::new(("preset-group", &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); @@ -95,7 +98,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label, ); - ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, &g, t.semibold(13.0), t.text_label); + ui.painter().text( + pos2(r.left() + 36.0, r.center().y), + Align2::LEFT_CENTER, + crate::i18n::builtin_label(&g, builtin), + t.semibold(13.0), + t.text_label, + ); if resp.clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); } @@ -108,7 +117,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { continue; } for pr in items { - let (pid, name, fav) = (pr.id.clone(), pr.name.clone(), pr.favorite); + let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); register(ui.ctx(), format!("preset:{pid}"), r); let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid); @@ -156,7 +165,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let (builtin, group) = (pr.builtin, pr.group.clone()); resp.context_menu(|ui| { - if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() { + if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() { let _ = app.run("preset.favorite", json!({"id": pid})); } if builtin { @@ -168,7 +177,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .clicked() { match app.run("preset.update", json!({"id": pid})) { - Ok(_) => app.toast(ui.ctx(), format!("Updated “{name}”")), + Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)), Err(e) => app.toast(ui.ctx(), e), } } diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 7633cdef6..ec4ddc57b 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -35,7 +35,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); let name = profile(&d.profile.id).map(|p| p.name).unwrap_or("Color"); ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Profile"), t.font(13.0), t.text_dim); - ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, name, t.font(14.0), t.text_label); + ui.painter().text(pos2(cr.left() + 84.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr(name), t.font(14.0), t.text_label); if d.profile.id != "lc.color" { super::edit::control(app, ui, &d, "profile.amount", true); ui.add_space(6.0); @@ -60,7 +60,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label, ); - ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label); + ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(13.0), t.text_label); ui.painter().text(pos2(r.right() - 22.0, r.center().y), Align2::RIGHT_CENTER, list.len().to_string(), t.font(12.0), t.text_dim); if resp.clicked() { ui.data_mut(|m| m.insert_temp(open_id, !open)); @@ -90,7 +90,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { && let Some(p) = profile(&saved_profile_id) { let s = with_profile(&d, p); - app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s }); + app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s }); } } else { ui.data_mut(|m| m.remove::<(PhotoId, String)>(last_hover_id)); @@ -129,7 +129,7 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); } let name_color = if applied { t.text } else { t.text_label }; - painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, p.name, t.font(12.0), name_color); + painter.text(pos2(cell.left() + 2.0, thumb.bottom() + 12.0), Align2::LEFT_CENTER, crate::i18n::tr(p.name), t.font(12.0), name_color); // favourite star (always shown when set, on hover otherwise) let fav = app.session.profile_favorites.iter().any(|f| f == p.id); let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0)); @@ -140,10 +140,10 @@ fn cell_ui(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: paint(painter, star.shrink(4.0), if fav { Icon::StarFilled } else { Icon::Star }, if fav || over_star { t.star } else { Color32::WHITE }); } if resp.hovered() && !applied && !over_star { - app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", p.name), settings: s.clone() }); + app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::tr(p.name)), settings: s.clone() }); ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p.id.to_string()))); } - let resp = resp.on_hover_text(format!("{} ({})", p.name, p.group)); + let resp = resp.on_hover_text(format!("{} ({})", crate::i18n::tr(p.name), crate::i18n::tr(p.group))); if resp.clicked() { if resp.interact_pointer_pos().is_some_and(|q| star.contains(q)) { let _ = app.run("profile.favorite", json!({"id": p.id})); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 1a07b5c6b..6985e1c99 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -48,7 +48,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { pub fn header(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); - ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, title, t.semibold(15.0), t.text); + ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(15.0), t.text); } pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) { @@ -69,7 +69,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { ui.label(crate::i18n::tr("Aspect Ratio")); let cur = d.crop.aspect.map(|(w, h)| format!("{} × {}", w as f64 / 100.0, h as f64 / 100.0)).unwrap_or_else(|| "Free".into()); - let r = ui.add(egui::Button::new(cur).frame(false)); + let r = ui.add(egui::Button::new(crate::i18n::tr(&cur)).frame(false)); register(ui.ctx(), "button:cropAspect", r.rect); egui::Popup::menu(&r).show(|ui| { for (label, a) in [ @@ -84,7 +84,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ("16 × 9", "16x9"), ("16 × 10", "16x10"), ] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { let _ = app.run("crop.aspect", json!({"aspect": a})); } } @@ -494,7 +494,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if crate::widgets::icon_button(ui, "editCaptureTime", Icon::Pencil, vec2(20.0, 20.0), false, true, "Edit Capture Time…").clicked() { let _ = app.run("dialog.captureTime", json!({})); } - let cap = p.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "—".into()); + let cap = p.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| "—".into()); ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.add(egui::Label::new(egui::RichText::new(cap).size(12.5).color(t.text)).truncate()); }); @@ -651,7 +651,7 @@ fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcr fn meta_field(app: &mut LightcraftApp, ui: &mut egui::Ui, label: &str, key: &str, value: &str, lines: usize) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); let id = egui::Id::new(("info-field", key)); let mut text: String = ui.data(|d| d.get_temp(id)).unwrap_or_else(|| value.to_string()); let edit = if lines > 1 { egui::TextEdit::multiline(&mut text).desired_rows(lines) } else { egui::TextEdit::singleline(&mut text) }; @@ -830,7 +830,8 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let mut auto: bool = ui.data(|d| d.get_temp(tab_id)).unwrap_or(false); let named_n = p.versions.iter().filter(|v| !v.auto).count(); let items = [("Named", "named"), ("Auto", "auto")]; - let labels = [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), format!("Auto ({})", p.versions.len() - named_n)]; + let labels = + [crate::i18n::tr_format!("Named ({named_n})", named_n = named_n), crate::i18n::tr_format!("Auto ({})", p.versions.len() - named_n)]; let items: Vec<(&str, &str)> = items.iter().zip(&labels).map(|((_, k), l)| (l.as_str(), *k)).collect(); if let Some(i) = crate::widgets::segmented(ui, "versionsTab", &items, Some(auto as usize), 2) { auto = i == 1; @@ -840,7 +841,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let shown: Vec<(usize, &lightcraft_catalog::Version)> = p.versions.iter().enumerate().filter(|(_, v)| v.auto == auto).collect(); if shown.is_empty() { let msg = if auto { "No automatic versions." } else { "No versions yet. Create one to keep this look." }; - ui.label(egui::RichText::new(msg).color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr(msg)).color(t.text_dim)); } // newest first for (i, v) in shown.into_iter().rev() { @@ -907,6 +908,10 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { /// "Sep 30, 2026, 12:00 PM" from an ISO time. fn short_time(iso: &str) -> String { + // Other languages use their own date patterns (`i18n::display_time`). + if crate::i18n::language() != crate::i18n::Locale::En { + return crate::i18n::display_time(iso); + } let long = lightcraft_catalog::dates::display_time(iso); // "September 30, 2026 at 12:00:00 PM" → month abbreviated, seconds dropped let (date, time) = long.split_once(" at ").map_or((long.as_str(), None), |(d, t)| (d, Some(t))); @@ -935,7 +940,13 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.painter().rect_filled(r, 3.0, t.hover); } paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon); - ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, &h.label, t.font(12.5), t.text_label); + ui.painter().text( + pos2(r.left() + 24.0, r.center().y), + Align2::LEFT_CENTER, + crate::i18n::history_label(&h.label, &app.session.presets), + t.font(12.5), + t.text_label, + ); if resp.clicked() { let _ = app.run("history.restore", json!({"index": i})); } diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index 2da811bb4..002232b11 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -13,7 +13,7 @@ LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 - 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框 (单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。 原生桌面版不受影响。 -- 日期分组标题(如 `Sunday, 20 September 2026`)沿用英文格式,尚未本地化。 +- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。 ## 翻译的维护 diff --git a/docs/localization-zh-hant.md b/docs/localization-zh-hant.md new file mode 100644 index 000000000..349cb20e0 --- /dev/null +++ b/docs/localization-zh-hant.md @@ -0,0 +1,46 @@ +# LightCraft 繁體中文(台灣)介面 + +從「編輯 → 語言 → 繁體中文(台灣)」切換,或開啟「設定 → 一般 → 語言」選擇。 +macOS 的設定也可由「LightCraft → 設定…」或 `⌘,` 開啟。 +語言立即生效,並儲存在應用程式設定的 `ui.json`,下次啟動會沿用。 +英文、簡體中文、繁體中文與日文可隨時互相切換。 + +繁中翻譯涵蓋 macOS 原生選單、程式內選單與子選單、鍵盤快速鍵說明、照片編輯、 +裁切、遮色片、匯入/匯出、設定、41 個內建編輯預設集與分類、內建描述檔、 +曲線與匯出預設集、歷史紀錄/版本面板、內建照片來源標題、日期群組與拍攝時間、 +照片庫開啟失敗提示及主要處理進度。 +切換介面不會變更操作指令 ID、照片檔名、使用者命名的相簿、預設集或中繼資料。 +未翻譯的技術錯誤、發行說明及部分新增文案仍使用英文。 + +## 建置與字型 + +使用既有 [craft-fonts](https://github.com/storytold/craft-fonts) 字型作為建置輸入: + +```sh +CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft +``` + +craft-fonts 目前沒有繁體中文專用字型:桌面版先以 Noto Sans CJK SC 顯示繁中文字(字形為大陸規範寫法),其次才是日文字型;不在此儲存庫新增字型檔。 +未指定 `CRAFT_FONTS_DIR` 的建置缺少 CJK 字形。 +Web 建置僅嵌入 BIZ UDPGothic Regular,尚未驗證完整繁中文字形覆蓋。 + +## 自動化與維護 + +`LIGHTCRAFT_LANGUAGE=zh-hant lightcraft-cli snapshot --demo -o zh-hant.png` 可繪製繁中畫面。 +接受 `zh-hant`、`zh-Hant`、`zh-TW`、`zh_TW.UTF-8` 與 `zh-HK`;儲存時統一使用 `zh-hant`。 +控制通道可切換語言: + +```json +{"method":"ui.set","params":{"language":"zh-hant"}} +{"method":"ui.menu.invoke","params":{"command":"app.language.traditionalChinese"}} +``` + +靜態文案位於 `crates/ui-egui/locales/zh-hant.json`,含變數的訊息位於 +`crates/ui-egui/locales/zh-hant-formats.json`。動態訊息由 Rust 在建置時檢查格式參數, +所有 `*-formats.json` 須保持相同英文鍵。新增語言的方法見 [`localization.md`](localization.md)。`{:.0}` 消耗英文複數字尾引數,繁中不顯示該字尾。 + +```sh +CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests +``` + +測試涵蓋翻譯目錄、指令/選單文字、語言切換、設定保存、資料名稱保持原樣及桌面字形。 diff --git a/docs/localization.md b/docs/localization.md index 3eb5efcaa..f6db12ffe 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,22 +1,28 @@ # Localization The interface ships in the language it is written in (English) plus every language in the table in -`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`) and Japanese -(`ja`). This file is the reference for **adding or maintaining a language**; the per-language notes -are in [`localization-zh-hans.md`](localization-zh-hans.md) and [`localization-ja.md`](localization-ja.md). +`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional +Chinese (`zh-hant`, Taiwan) and Japanese (`ja`). This file is the reference for **adding or maintaining a language**; the per-language notes +are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md) and +[`localization-ja.md`](localization-ja.md). ## Adding a language 1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to `locales/.json` and `locales/-formats.json`, then translate the values. Keys are the - English source strings and must stay byte-for-byte identical — `cargo test -p lightcraft-ui-egui - i18n::tests` fails on a missing, extra or empty entry. + English source strings and must stay byte-for-byte identical. The plain catalog may grow at its + own pace: a message it lacks shows in English, and `cargo test -p lightcraft-ui-egui i18n::tests + -- --nocapture` lists the gaps (it fails on an empty entry or mismatched placeholders). The + formats catalog must carry every message (the build fails otherwise), including the date + patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and + capture times use in every language but English; weekday names go in the plain catalog. 2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`: ```rust language_table! { En, "en"; ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json"); + ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json"); Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json"); } ``` diff --git a/docs/parity.md b/docs/parity.md index fc9226782..63f0cb512 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -503,8 +503,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader | -| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.japanese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese and Japanese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Technical errors/release notes and date grouping headers remain English, and the web build embeds only the Japanese face. | +| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Traditional Chinese catalog is the most complete, the others fall back to English for messages they lack. Technical errors/release notes remain English; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |