Merge origin/main into fix/arw-saturated-hues

Conflicts with issue #150 (NEF colour from the camera JPEG): the per-file
look, its hue/saturation/value table and chroma curve, and camera
profiles now apply to every `file_local_look` format (ARW, NEF, NRW);
`calibrate` also collects NEF/NRW. Render cache version 11 (main 9,
branch 10).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Harald Hoyer
2026-10-07 13:51:08 +02:00
co-authored by Claude Opus 5.5
77 changed files with 5917 additions and 366 deletions
Generated
+2
View File
@@ -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",
+10 -3
View File
@@ -334,16 +334,19 @@ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
```
**Japanese text** needs the shared font repo, an optional build input (official releases always include it):
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
```
Without it LightCraft builds and runs the same, but Japanese text has no glyphs. Fonts are never committed to this
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
@@ -432,7 +435,7 @@ Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
Inter (SIL OFL 1.1). Builds made with [craft-fonts](https://github.com/storytold/craft-fonts) (all official releases)
also embed its Japanese fonts (BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
also embed its Chinese and Japanese fonts (Noto Sans CJK SC, BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). All icons are original.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
@@ -446,3 +449,7 @@ Forks and modified versions must remove them.
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## 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)
+16 -14
View File
@@ -23,21 +23,21 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW has a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~40 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW and NEF have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; Japanese/English UI (see docs/localization-ja.md); remaining technical errors and other languages; accessibility partial; headless UI tests time out under machine load |
| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese and Japanese UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load |
### By kind of user
| User | Readiness | What blocks them |
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW preview estimates are only a starting point) |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW and NEF preview estimates are only a starting point) |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models |
@@ -46,8 +46,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Sony ARW's file-local fit (matrix + tone curve from its own
JPEG) is the first step; generalise it to the other makes' raws (NEF, RW2, PEF, ORF…), then validate fidelity.
matrices the files carry themselves, then chart shots. Sony ARW and Nikon NEF have the file-local fit (matrix + tone
curve from their own JPEG); generalise it to the other makes' raws (RW2, PEF, ORF…), then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
@@ -82,12 +82,12 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW and NEF preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |
| M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; Japanese/English localisation 🟡; accessibility and other locales ⬜) |
| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese localisation 🟡; accessibility and further locales ⬜) |
## Parity estimate (feature count updated 2026-10-05; effort estimate from 2026-10-02)
@@ -192,12 +192,14 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
export hardening, clippy 1.99. Added the honest *Where we stand* assessment and *Where we're going* priorities; added
tracker rows for camera colour, camera coverage and render fidelity.
## Japanese interface and text watermarks
## Chinese and Japanese interfaces, and text watermarks
English/Japanese interface language is persisted in UI state. Core menus have Japanese
translations; untranslated panels and dialogs retain English. Japanese glyphs (UI: BIZ UDPGothic;
watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made with the
optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the
language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another
language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are
translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI:
BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made
with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept
`vertical: true` in export JSON/presets and expose an orientation selector. Japanese
characters stay upright in top-to-bottom columns, with newlines starting columns to the
left. This is basic lettering, without tate-chu-yoko, ruby, kinsoku, or general vertical
+39 -33
View File
@@ -87,7 +87,7 @@ USAGE:
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
(Sony ARW): up to N files spread over the folders (default 300; 0 = all), pooled per
(Sony ARW, Nikon NEF): up to N files spread over the folders (default 300; 0 = all), pooled per
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
take their colour from the profile and only their tone from their own JPEG.
@@ -154,7 +154,7 @@ fn main() -> ExitCode {
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("arw"));
let is_raw = |p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw"].iter().any(|x| e.eq_ignore_ascii_case(x)));
if path.is_file() {
if is_raw(path) {
out.push(path.to_path_buf());
@@ -532,39 +532,39 @@ fn run(args: &[String]) -> Result<(), String> {
if steps.is_empty() {
return Err("run: no command given (see `lightcraft-cli commands`)".into());
}
let mut backend: Box<dyn Backend> = 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<Value> = 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<dyn Backend> =
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<Value> = r["imported"].as_array().cloned().unwrap_or_default();
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
if let Some(first) = ids.first().cloned() {
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
}
}
Box::new(h)
}
};
let mut out = std::io::stdout().lock();
let mut failed = 0;
for s in &steps {
@@ -590,6 +590,12 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s
args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value"))
}
/// The port an `--connect` address names, for the recovery hint. The whole address when it has
/// no port, so a hint is never built from a guess.
fn connect_port(addr: &str) -> &str {
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
}
fn render(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut output = None;
+10
View File
@@ -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}");
}
+22 -1
View File
@@ -171,7 +171,7 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
out.push(';');
}
}
let mut s = format!("{};", lightcraft_ui_egui::i18n::is_japanese());
let mut s = format!("{};", lightcraft_ui_egui::i18n::language().code());
for (t, items) in bar {
s.push_str(t);
walk(items, &mut s);
@@ -419,6 +419,27 @@ impl NativeMenu {
mod tests {
use super::*;
/// The menu model's titles stay English, so only the language can tell the native menu to
/// rebuild its titles (and the macOS app menu): every language switch — also between two CJK
/// languages — must change the structure key.
#[test]
fn switching_language_rebuilds_native_menu_structure() {
use lightcraft_ui_egui::i18n::{Locale, set_language};
let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
set_language(Locale::En);
let bar = menu_bar(&app);
let mut seen = std::collections::BTreeSet::new();
for language in Locale::ALL {
set_language(*language);
assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
}
set_language(Locale::ZhHant);
assert_eq!(label_text("File"), "檔案");
assert_eq!(label_text("Settings…"), "設定…");
assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
set_language(Locale::En);
}
#[test]
fn shortcuts_map_to_accelerators() {
assert_eq!(accelerator("Cmd+Shift+Z"), Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)));
+1
View File
@@ -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 }
+1 -1
View File
@@ -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};
+24 -3
View File
@@ -183,6 +183,11 @@ pub struct Meta {
pub copyright_url: String,
pub creator: String,
pub keywords: Vec<String>,
/// 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<lightcraft_meta::Region>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -331,10 +336,11 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW reader has vendor WB multipliers but no measured camera illuminant.
/// Use adjustments relative to the camera's as-shot look, as for rendered photographs.
/// The current ARW and NEF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats).
pub fn relative_wb(&self) -> bool {
self.develops_raw() && self.format.eq_ignore_ascii_case("ARW")
self.develops_raw() && ["ARW", "NEF", "NRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
@@ -421,4 +427,19 @@ mod edited_tests {
p.develop = Arc::new(DevelopSettings::default());
assert!(!p.is_edited(), "a full reset is unedited too");
}
#[test]
fn sony_and_nikon_raws_use_relative_white_balance() {
for (name, format, relative) in [("a.arw", "ARW", true), ("a.nef", "NEF", true), ("a.nrw", "nrw", true), ("a.dng", "DNG", false)] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.as_shot_wb = Some((5200.0, 4.0));
assert_eq!(p.relative_wb(), relative, "{format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
// shown from the embedded preview: a rendered image, not a relative-WB raw
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb());
}
}
}
+70 -3
View File
@@ -39,6 +39,8 @@ pub struct Filter {
pub date: Option<String>,
/// A keyword; hierarchical keywords match their children too (`travel` finds `travel|italy`).
pub keyword: Option<String>,
/// A person: photos with a named face region of this name (case-insensitive), as read from XMP.
pub person: Option<String>,
pub camera: Option<String>,
/// Lens (case-insensitive substring).
pub lens: Option<String>,
@@ -113,6 +115,7 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"camera" => p.meta.camera.to_lowercase().contains(val),
"lens" => p.meta.lens.to_lowercase().contains(val),
"keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)),
"person" | "who" => has_person(p, val),
"type" | "kind" => format!("{:?}", p.kind).eq_ignore_ascii_case(val),
"edited" => (val == "true" || val == "yes") == p.is_edited(),
"date" => p.date().starts_with(val),
@@ -125,6 +128,12 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
}
/// Whether `p` has a named face region called `name` (case-insensitive).
fn has_person(p: &Photo, name: &str) -> bool {
let name = name.trim().to_lowercase();
p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name))
}
impl Filter {
/// Human-readable summary of the active rules (smart album tooltips, `album.list`).
pub fn describe(&self) -> String {
@@ -152,9 +161,14 @@ impl Filter {
if let Some(e) = self.edited {
v.push(if e { "edited".into() } else { "unedited".into() });
}
for (name, val) in
[("keyword", &self.keyword), ("camera", &self.camera), ("lens", &self.lens), ("date", &self.date), ("imported", &self.imported)]
{
for (name, val) in [
("keyword", &self.keyword),
("person", &self.person),
("camera", &self.camera),
("lens", &self.lens),
("date", &self.date),
("imported", &self.imported),
] {
if let Some(x) = val {
v.push(format!("{name} {x}"));
}
@@ -267,6 +281,11 @@ impl Filter {
{
return false;
}
if let Some(n) = &self.person
&& !has_person(p, n)
{
return false;
}
if let Some(c) = &self.camera
&& !p.meta.camera.eq_ignore_ascii_case(c)
{
@@ -330,6 +349,54 @@ impl Catalog {
}
m.into_iter().collect()
}
/// The people named on faces in the library (MWG regions read from XMP): how many photos each
/// appears in and the photo showing their largest face (for a card's picture). Most photos first,
/// then by name. Names that differ only in case are one person, shown as first seen; a person
/// twice in one photo counts once.
pub fn people(&self) -> Vec<Person> {
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<Person> {
let filter = Filter { person: None, ..filter.clone() };
let mut m: std::collections::HashMap<String, (Person, f64)> = Default::default();
for p in self.photos().filter(|p| filter.matches(p, self)) {
let mut seen: Vec<String> = Vec::new();
for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) {
let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
let key = name.to_lowercase();
// the face's size in pixels of the photo
let area = (r.rect.x1 - r.rect.x0) * (r.rect.y1 - r.rect.y0) * p.width as f64 * p.height as f64;
let (person, best) =
m.entry(key.clone()).or_insert_with(|| (Person { name: name.to_string(), count: 0, photo: p.id, face: r.rect }, area));
if !seen.contains(&key) {
person.count += 1;
seen.push(key);
}
if area > *best || (area == *best && p.id < person.photo) {
(person.photo, person.face, *best) = (p.id, r.rect, area);
}
}
}
let mut v: Vec<Person> = 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)]
+83
View File
@@ -297,3 +297,86 @@ fn folder_identity_ignores_spelling() {
assert!(folder_within("/a/b", "/"));
assert!(folder_within("C:\\x", "c:\\"));
}
/// People come from named *face* regions: counted once per photo, case-insensitive, most photos
/// first; pets and unnamed faces are not people; the `person` filter and `person:` token match.
#[test]
fn people_from_named_face_regions() {
use lightcraft_meta::{Rect, Region, RegionKind};
let region = |name: Option<&str>, kind: RegionKind| Region {
rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 },
kind,
name: name.map(str::to_string),
description: None,
};
let mut c = Catalog::new();
let mut add = |name: &str, regions: Vec<Region>| {
let id = c.alloc_photo_id();
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00");
p.meta.regions = regions;
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
id
};
let a = add(
"a.jpg",
vec![region(Some("Jane Doe"), RegionKind::Face), region(Some("jane doe"), RegionKind::Face), region(Some("Rex"), RegionKind::Pet)],
);
let b =
add("b.jpg", vec![region(Some("JANE DOE"), RegionKind::Face), region(Some("John Roe"), RegionKind::Face), region(None, RegionKind::Face)]);
let big = Rect { x0: 0.2, y0: 0.2, x1: 0.8, y1: 0.8 };
let d = add("d.jpg", vec![Region { rect: big, ..region(Some("John Roe"), RegionKind::Face) }]);
let e = add("e.jpg", vec![region(Some("Sam"), RegionKind::Face)]);
add("f.jpg", vec![]);
let people = c.people();
let summary: Vec<(&str, usize)> = people.iter().map(|p| (p.name.as_str(), p.count)).collect();
assert_eq!(summary, vec![("Jane Doe", 2), ("John Roe", 2), ("Sam", 1)], "once per photo, pets and unnamed faces left out");
// the picture is the person's largest face; ties go to the lower photo id
assert_eq!((people[0].photo, people[0].face), (a, Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }));
assert_eq!((people[1].photo, people[1].face), (d, big), "the larger face wins over an earlier, smaller one");
let q = |f: Filter| c.query(&f, &Sort::default());
assert_eq!(q(Filter { person: Some("jane doe".into()), ..Default::default() }).len(), 2);
let mut got = q(Filter { person: Some("John Roe".into()), ..Default::default() });
got.sort();
assert_eq!(got, vec![b, d]);
assert!(q(Filter { person: Some("Rex".into()), ..Default::default() }).is_empty(), "a pet is not a person");
assert_eq!(q(Filter { text: "person:SAM".into(), ..Default::default() }), vec![e], "the search token finds a person, any case");
assert!(q(Filter { person: Some("Jane Doe".into()), ..Default::default() }).contains(&a));
// other filters narrow who is offered (so picking a person never ends in an empty grid); the
// `person` filter itself does not
c.apply(Op::SetRating { id: b, rating: 3 }).unwrap();
let names = |f: Filter| c.people_in(&f).into_iter().map(|p| (p.name, p.count)).collect::<Vec<_>>();
let rated = Filter { rating: 1, ..Default::default() };
assert_eq!(
names(rated.clone()),
vec![("JANE DOE".to_string(), 1), ("John Roe".to_string(), 1)],
"only the rated photo's people, counted within it, spelled as first seen there"
);
assert_eq!(
names(Filter { person: Some("Sam".into()), ..rated }),
names(Filter { rating: 1, ..Default::default() }),
"the person filter is ignored"
);
assert_eq!(names(Filter { rating: 5, ..Default::default() }), vec![], "nobody in the filtered photos");
}
/// Most photos have no regions: the field is left out of the catalog JSON then, and a catalog written
/// before regions existed (no `regions` key) reads with none.
#[test]
fn empty_regions_are_not_serialized_and_default_when_missing() {
let m = Meta { keywords: vec!["k".into()], ..Default::default() };
let v = serde_json::to_value(&m).unwrap();
assert!(v.get("regions").is_none(), "{v}");
let back: Meta = serde_json::from_value(v).unwrap();
assert!(back.regions.is_empty());
let mut with = m.clone();
with.regions.push(lightcraft_meta::Region {
rect: lightcraft_meta::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 },
kind: lightcraft_meta::RegionKind::Face,
name: Some("A".into()),
description: None,
});
let back: Meta = serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
assert_eq!(back, with);
}
+47 -9
View File
@@ -1,5 +1,6 @@
//! Estimate an ARW starting look from its own JPEG. Colour and luminance are fitted separately;
//! the JPEG supplies correspondences only, never output pixels or a replacement for RAW editing.
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF) from its
//! own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
//! never output pixels or a replacement for RAW editing.
//! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a
//! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals
//! (applied like a DNG `ProfileHueSatMap`) corrects that when it also improves the held-out pixels.
@@ -9,7 +10,7 @@ use lightcraft_raster::{
Rgb32f,
resample::{Filter, fit},
};
use lightcraft_raw::{RawImage, color::CameraTransform, profile::HsvTable};
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable};
#[derive(Clone, Debug)]
pub(crate) struct CameraLook {
@@ -25,8 +26,16 @@ const PROXY: usize = 96;
/// Long edge of the proxies pooled for a camera profile: small objects (a shirt) get 4× the samples.
pub(crate) const PROFILE_PROXY: usize = 192;
/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix:
/// their starting look is fitted to the file's own JPEG, and white balance is relative to the
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
/// same formats by file extension.
pub(crate) fn file_local_look(format: RawFormat) -> bool {
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw)
}
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
if !transform.matrix_is_fallback || raw.format != lightcraft_raw::RawFormat::Arw {
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
return None;
}
let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?;
@@ -37,7 +46,8 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
let look = fit_pairs_with(&sensor, &reference, colour)?;
if lightcraft_pipeline::profiling() {
eprintln!(
"[profile] ARW camera look: {:?}, {:?}, hue/sat table {}, camera profile {}",
"[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile {}",
raw.format,
look.matrix.0,
look.tone,
look.hue_sat.is_some(),
@@ -72,10 +82,11 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
Some((sensor, reference))
}
/// Colour training pairs of one ARW for a camera profile: white-balanced camera RGB (with the
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for other files or unusable previews.
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
/// colour matrices, other files or unusable previews.
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3], [f64; 3])>> {
if raw.format != lightcraft_raw::RawFormat::Arw || lightcraft_raw::color::has_matrix(&raw.color) {
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
return None;
}
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
@@ -240,7 +251,11 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
})
.sum();
if lightcraft_pipeline::profiling() {
eprintln!("[profile] ARW holdout RMS {:.5} -> {:.5} ({samples} channels)", (before / samples as f64).sqrt(), (after / samples as f64).sqrt());
eprintln!(
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)",
(before / samples as f64).sqrt(),
(after / samples as f64).sqrt()
);
}
if samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
return None;
@@ -715,6 +730,29 @@ mod tests {
assert!(fit_hue_sat(&[], &Mat3::IDENTITY).is_none(), "too few samples");
}
#[test]
fn sony_and_nikon_raws_get_a_file_local_look() {
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw].into_iter().all(file_local_look));
assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look));
}
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
/// balance is relative to the as-shot look.
#[test]
fn corpus_nef_gets_a_camera_look() {
let path = std::env::var_os("LIGHTCRAFT_CORPUS")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
.join("raw/nef-nikon-d7500-lossless14.nef");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
return;
};
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.camera_tone.is_some(), "no camera look fitted");
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
}
#[test]
fn rejects_monochrome_invalid_and_unrelated_previews() {
let mut sensor = Rgb32f::new(32, 32);
+3 -3
View File
@@ -1,6 +1,6 @@
//! Camera colour profiles of our own, pooled from many photos of one camera model.
//!
//! A raw file without colour matrices (Sony ARW) gets its look fitted to its own embedded camera
//! A raw file without colour matrices (Sony ARW, Nikon NEF) gets its look fitted to its own embedded camera
//! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a
//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate`
//! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value
@@ -174,8 +174,8 @@ pub struct Pool {
const PAIRS_PER_FILE: usize = 4000;
impl Pool {
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW
/// without colour matrices, no usable camera JPEG, too little colour).
/// Add one raw file's colour pairs. `Ok(None)` when the file can't contribute (not an ARW or
/// NEF without colour matrices, no usable camera JPEG, too little colour).
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
let raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
+69 -3
View File
@@ -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<Op> {
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<Value> {
@@ -199,8 +226,18 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
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<CommandSpec> {
),
]
}
#[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());
}
}
+63 -3
View File
@@ -263,7 +263,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Filter",
[],
None,
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, camera?}",
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, keyword?, person?, camera?}",
always,
|s, p| {
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
@@ -446,6 +446,66 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)),
cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)),
cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)),
// ---- face / pet regions
cmd!(
"photo.removeRegion",
"Remove Face Box",
[],
None,
"{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back",
always,
|s, p| {
let id =
p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?;
let index = p
.get("index")
.and_then(Value::as_u64)
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad("photo.removeRegion", "missing or invalid `index`"))?;
let mut meta = s.catalog.photo(id).ok_or_else(|| bad("photo.removeRegion", "no such photo"))?.meta.clone();
if index >= meta.regions.len() {
return Err(bad("photo.removeRegion", "no such region"));
}
let gone = meta.regions.remove(index);
s.commit("Remove Face Box", Op::SetMeta { id, meta: Box::new(meta) })?;
s.skip_auto_write = true;
Ok(json!({"removed": gone.name}))
}
),
cmd!(
"photo.setRegion",
"Resize Face Box",
[],
None,
"{id?, index, rect: {x0, y0, x1, y1}} — set one face / pet region's box (normalized, in the photo's upright frame; clamped to the photo, at least 0.5 % each way) in the catalog; undoable. Like photo.removeRegion it never rewrites the XMP sidecar",
always,
|s, p| {
const C: &str = "photo.setRegion";
// a drag is one undo step however many frames it took: the caller commits once, on release
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
let index = p
.get("index")
.and_then(Value::as_u64)
.and_then(|i| usize::try_from(i).ok())
.ok_or_else(|| bad(C, "missing or invalid `index`"))?;
let r = p.get("rect").ok_or_else(|| bad(C, "missing `rect`"))?;
let num = |k: &str| {
r.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(C, format!("`rect.{k}` must be a finite number")))
};
let (a, b, c, d) = (num("x0")?, num("y0")?, num("x1")?, num("y1")?);
let (x0, x1) = (a.min(c).clamp(0.0, 1.0), a.max(c).clamp(0.0, 1.0));
let (y0, y1) = (b.min(d).clamp(0.0, 1.0), b.max(d).clamp(0.0, 1.0));
if x1 - x0 < 0.005 || y1 - y0 < 0.005 {
return Err(bad(C, "the box would be too small"));
}
let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone();
let region = meta.regions.get_mut(index).ok_or_else(|| bad(C, "no such region"))?;
region.rect = lightcraft_geom::Rect { x0, y0, x1, y1 };
s.commit("Resize Face Box", Op::SetMeta { id, meta: Box::new(meta) })?;
s.skip_auto_write = true;
Ok(json!({"rect": {"x0": x0, "y0": y0, "x1": x1, "y1": y1}}))
}
),
// ---- delete / restore
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
@@ -453,11 +513,11 @@ pub fn specs() -> Vec<CommandSpec> {
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::<Vec<_>>();
s.commit("Delete Permanently", Op::Batch { ops })?;
+7 -6
View File
@@ -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<ab_glyph::FontRef<'static>> {
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()
}
+3 -2
View File
@@ -38,6 +38,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
copyright_url: m.copyright_url.clone().unwrap_or_default(),
creator: m.artist.clone().unwrap_or_default(),
keywords: m.keywords.clone(),
regions: m.regions.clone(),
};
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
}
@@ -132,7 +133,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
}
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
let relative = raw.format == lightcraft_raw::RawFormat::Arw && !lightcraft_raw::color::has_matrix(&raw.color);
let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color);
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
let embedded_lens = embedded_lens(&raw);
return Ok(ProbeInfo {
@@ -288,7 +289,7 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
}
let (temp, tint) = xy_to_temp_tint(xy);
let relative = raw.format == lightcraft_raw::RawFormat::Arw && t.matrix_is_fallback;
let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback;
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone }));
+66 -3
View File
@@ -1,9 +1,9 @@
//! Fonts from the optional craft-fonts build input (storytold/craft-fonts).
//!
//! Built with `CRAFT_FONTS_DIR=<craft-fonts checkout>`, [`CRAFT_FONTS`] holds every font in its
//! manifest (today: the Japanese UI and document fonts); otherwise it is empty and LightCraft uses
//! only its own bundled fonts (Inter). Both the egui UI and the export watermark renderer read it
//! from here. See craftrules `standards/fonts.md`.
//! manifest (today: the Japanese and Simplified-Chinese UI and document fonts); otherwise it is
//! empty and LightCraft uses only its own bundled fonts (Inter). Both the egui UI and the export
//! watermark renderer read it from here. See craftrules `standards/fonts.md`.
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
pub struct CraftFont {
@@ -26,6 +26,13 @@ impl CraftFont {
pub fn is_mincho(&self) -> bool {
self.family.contains("Mincho")
}
/// Whether the font can stand in for `script`: it is meant for it, or — until craft-fonts has a
/// Traditional Chinese face — it is a Simplified Chinese face, which carries the Traditional
/// characters too (with mainland glyph forms; still closer than the Japanese faces).
pub fn serves(&self, script: &str) -> bool {
self.covers(script) || (script == "Hant" && self.covers("Hans"))
}
}
/// The craft-fonts faces for Japanese, in `fonts`' order (empty without craft-fonts).
@@ -33,6 +40,43 @@ pub fn japanese(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static Cr
fonts.iter().filter(|f| f.covers("Jpan"))
}
/// The craft-fonts faces for any CJK script: what any CJK text needs, regardless of language.
pub fn cjk(fonts: &'static [CraftFont]) -> impl Iterator<Item = &'static CraftFont> {
fonts.iter().filter(|f| f.scripts.iter().any(|script| is_cjk_script(script)))
}
/// Whether an ISO 15924 script is written with Han, kana or hangul characters (Unicode's CJK
/// scripts), which Inter has no glyphs for.
pub fn is_cjk_script(script: &str) -> bool {
matches!(script, "Hans" | "Hant" | "Jpan" | "Kore" | "Hang" | "Hani" | "Bopo" | "Yiii" | "Nshu" | "Tang")
}
/// The craft-fonts faces to fall back on for `script`, in preference order: the faces of that script
/// first (for `style` when asked), then every other CJK face. One CJK face list serves every
/// language, but Han characters are shared between them: a Chinese reader must get the Chinese
/// forms and a Japanese reader the Japanese ones, so the active language's own script comes first.
/// Empty without craft-fonts.
pub fn cjk_fallback<'a>(fonts: &'a [CraftFont], script: &str, style: &str) -> Vec<&'a CraftFont> {
let mut faces: Vec<&CraftFont> = fonts.iter().filter(|font| covers_cjk(font)).collect();
faces.sort_by_key(|font| {
(
// The language's own script first. A face dedicated to the script is preferred over a
// multi-script face (Noto Sans CJK SC claims `Hans,Latn`; a Japanese face claims `Jpan`).
!font.serves(script),
font.scripts.len() != 1,
font.family != "BIZ UDPGothic",
font.style != style,
font.is_mincho(),
)
});
faces
}
/// Whether the font is for any CJK script.
fn covers_cjk(font: &CraftFont) -> bool {
font.scripts.iter().any(|script| is_cjk_script(script))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -49,4 +93,23 @@ mod tests {
}
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
}
/// Each language's own faces come first, so shared Han keeps the language's forms, and Latin
/// text still reaches Inter before any CJK fallback.
#[test]
fn a_language_gets_its_own_script_first() {
static FACES: &[CraftFont] = &[
CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
CraftFont { family: "Noto Sans CJK SC", style: "Regular", scripts: &["Hans", "Latn"], bytes: &[] },
CraftFont { family: "BIZ UDMincho", style: "Regular", scripts: &["Jpan", "Latn"], bytes: &[] },
];
let first = |script: &str| cjk_fallback(FACES, script, "Regular").first().map(|f| f.family);
assert_eq!(first("Hans"), Some("Noto Sans CJK SC"));
assert_eq!(first("Jpan"), Some("BIZ UDPGothic"));
// No Traditional Chinese face yet: the Chinese face stands in before the Japanese ones.
assert_eq!(first("Hant"), Some("Noto Sans CJK SC"));
// Every CJK face is still offered to every language.
assert_eq!(cjk_fallback(FACES, "Hans", "Regular").len(), 3);
assert_eq!(cjk_fallback(&[], "Hans", "Regular").len(), 0);
}
}
+9 -2
View File
@@ -126,6 +126,9 @@ pub struct Session {
pub undo: Vec<UndoEntry>,
pub redo: Vec<UndoEntry>,
pub interaction: Option<Interaction>,
/// 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<Value>,
/// The folder on disk the [`LibrarySource::Folder`] view browses.
@@ -224,6 +227,7 @@ impl Session {
undo: Vec::new(),
redo: Vec::new(),
interaction: None,
skip_auto_write: false,
clipboard: None,
meta_clipboard: None,
browse: None,
@@ -309,8 +313,11 @@ impl Session {
self.journal.drain(..1000);
}
}
if r.is_ok() && self.depth == 0 && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
self.auto_write_sidecars(&self.pending_log[log_start..]);
if self.depth == 0 {
let skip = std::mem::take(&mut self.skip_auto_write);
if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
self.auto_write_sidecars(&self.pending_log[log_start..]);
}
}
if self.depth == 0 && self.library.is_some() {
// Make the command durable before reporting success. When the command's own records
+5 -11
View File
@@ -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<crate::Browse>,
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::<ViewFile>(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<u8> {
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) {
+43 -1
View File
@@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 10;
pub const RENDER_CACHE_VERSION: u64 = 11;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
@@ -718,6 +718,48 @@ impl crate::Session {
})
}
/// A square close-up of `face` (normalized, in the photo's upright frame) for a People card: the
/// photo's own look with its crop replaced by a square around the face (room for hair and chin),
/// `edge` pixels across (≤ 512). The source level is the smallest that keeps the face sharp, up
/// to the preview. Cached like [`Self::variant_job`], and `key` likewise follows the content.
pub fn face_job(&mut self, id: PhotoId, face: lightcraft_geom::Rect, edge: usize) -> Option<RenderJob> {
let p = self.catalog.photo(id)?.clone();
// `face` is on the upright (EXIF-oriented) photo; the crop below is in the frame after the
// user's Rotate Left/Right, so carry the box (and the photo's size) over to it.
let orient = p.develop.orientation;
let face = orient.map_norm_rect(face);
let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1)));
let (w, h) = if orient.swaps_axes() { (h, w) } else { (w, h) };
let side = ((face.x1 - face.x0) * w).max((face.y1 - face.y0) * h) * 1.7;
// finite, within the photo, and never wider than its short side (so the clamps below are valid)
let side = if side.is_finite() { side.clamp(1.0, w.min(h)) } else { w.min(h) };
let (cx, cy) = ((face.x0 + face.x1) / 2.0 * w, (face.y0 + face.y1) / 2.0 * h);
let (cx, cy) = (if cx.is_finite() { cx } else { w / 2.0 }, if cy.is_finite() { cy } else { h / 2.0 });
let (x0, y0) = (cx.clamp(side / 2.0, w - side / 2.0) - side / 2.0, cy.clamp(side / 2.0, h - side / 2.0) - side / 2.0);
let rect = lightcraft_geom::Rect { x0: x0 / w, y0: y0 / h, x1: (x0 + side) / w, y1: (y0 + side) / h };
let mut settings = (*p.develop).clone();
settings.crop = lightcraft_develop::Crop { geometry: lightcraft_geom::CropGeometry { rect, angle: 0.0 }, ..Default::default() };
let edge = edge.clamp(16, SourceLevel::Thumb.max_edge());
// pixels along the long edge that leave `edge` across the crop
let needed = (edge as f64 * w.max(h) / side).ceil().min(SourceLevel::Preview.max_edge() as f64);
let level = SourceLevel::for_size(needed as usize);
let source = self.media.source_ref(&p, level);
let ck = Self::variant_key(&p, &settings, edge);
Some(RenderJob {
photo: id,
level,
source,
origin: p.source.clone(),
info: source_info(&p),
settings: Arc::new(settings),
request: RenderRequest { apply_crop: true, ..RenderRequest::fit(edge, edge) },
key: (ck.0 as u64) ^ ((ck.0 >> 64) as u64),
cache: Some((self.media.rendered.clone(), ck)),
stages: None,
view_cache: None,
})
}
/// Size-independent cache key of a photo's view render (loupe) for its current settings.
fn view_key(p: &Photo, apply_crop: bool) -> Hash128 {
Hasher128::new()
+16
View File
@@ -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<String, Vec<(PathBuf, String)>>,
/// The entries of `by_name` that are symlinks, found on the first check that needs them.
links: std::sync::OnceLock<Vec<(PathBuf, String)>>,
}
impl OriginalGuard {
@@ -32,6 +34,7 @@ impl OriginalGuard {
/// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`).
pub fn add(&mut self, path: &Path, label: &str) {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return };
self.links = std::sync::OnceLock::new();
let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what));
put(name.clone(), path.to_path_buf(), format!("the original of {label}"));
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
@@ -58,6 +61,19 @@ impl OriginalGuard {
}
}
}
// An imported symlink can have a different name from its original. A write to
// that original would change what the library reads through the link. Only do
// this fallback for existing targets that the name index did not protect; the links are
// looked up once per guard, not once per target.
let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect());
for (p, what) in links {
if same_file(p, target) {
return Err(format!(
"{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)",
target.display()
));
}
}
Ok(())
}
}
+10
View File
@@ -99,6 +99,7 @@ pub struct SidecarData {
pub alt_text: Option<String>,
pub extended_description: Option<String>,
pub keywords: Option<Vec<String>>,
pub regions: Option<Vec<lightcraft_meta::Region>>,
/// 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<String>,
@@ -133,6 +134,12 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
alt_text: m.alt_text.clone(),
extended_description: m.extended_description.clone(),
keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()),
// A sidecar that has `mwg-rs:Regions` at all (even an empty list) was written by an app that
// knows about regions, so it's authoritative: its list, empty or not, replaces the catalog's.
// One without it (most writers, LightCraft's own included, which keeps another app's
// `mwg-rs:Regions` byte for byte but never writes one) says nothing about regions, and the
// photo's are kept.
regions: d.values.contains_key("mwg-rs:Regions").then(|| m.regions.clone()),
captured: m.capture_time.map(|d| d.to_iso()),
..Default::default()
};
@@ -202,6 +209,9 @@ pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> 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)) => {
+56
View File
@@ -727,3 +727,59 @@ fn curve_reset_by_channel_and_whole() {
assert_eq!(active_dev(&s).curve, lightcraft_develop::ToneCurve::default());
assert!(s.execute("curve.reset", &json!({"channel": "alpha"})).is_err());
}
/// A People card's close-up is a square (in pixels) inside the photo, around the face, whatever the
/// face rectangle says: corners, oversized and degenerate rectangles never leave the frame or panic.
#[test]
fn face_job_crops_a_square_inside_the_photo() {
use lightcraft_geom::Rect;
let mut s = demo();
let id = s.active().unwrap();
let (w, h) = {
let p = s.catalog.photo(id).unwrap();
(f64::from(p.width), f64::from(p.height))
};
let faces = [
Rect { x0: 0.40, y0: 0.30, x1: 0.50, y1: 0.45 },
Rect { x0: 0.0, y0: 0.0, x1: 0.05, y1: 0.05 },
Rect { x0: 0.95, y0: 0.95, x1: 1.0, y1: 1.0 },
Rect { x0: -5.0, y0: -5.0, x1: 5.0, y1: 5.0 },
Rect { x0: 0.5, y0: 0.5, x1: 0.5, y1: 0.5 },
Rect { x0: f64::NAN, y0: 0.5, x1: 0.6, y1: f64::INFINITY },
];
for face in faces {
let job = s.face_job(id, face, 256).expect("a job for a photo in the library");
let r = job.settings.crop.geometry.rect;
assert!((0.0..=1.0).contains(&r.x0) && (0.0..=1.0).contains(&r.y0) && r.x1 <= 1.0 + 1e-9 && r.y1 <= 1.0 + 1e-9, "{face:?} → {r:?}");
assert!(r.x1 > r.x0 && r.y1 > r.y0, "{face:?} → {r:?}");
let (pw, ph) = ((r.x1 - r.x0) * w, (r.y1 - r.y0) * h);
assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in pixels: {face:?} → {pw} × {ph}");
assert_eq!(job.settings.crop.geometry.angle, 0.0);
}
// a face around (0.45, 0.375) is centred in its crop (room to spare on every side)
let r = s.face_job(id, faces[0], 256).unwrap().settings.crop.geometry.rect;
assert!((((r.x0 + r.x1) / 2.0) - 0.45).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.375).abs() < 1e-9, "{r:?}");
assert!(s.face_job(lightcraft_catalog::PhotoId(u64::MAX), faces[0], 256).is_none());
}
/// Regions are on the upright photo; after Rotate Right the close-up still frames the face: the box
/// is carried into the rotated frame the crop lives in, and the crop stays square in rotated pixels.
#[test]
fn face_job_follows_rotate_right() {
use lightcraft_geom::Rect;
let mut s = demo();
let id = s.active().unwrap();
let (w, h) = {
let p = s.catalog.photo(id).unwrap();
(f64::from(p.width), f64::from(p.height))
};
// a small face left of centre on the upright photo
let face = Rect { x0: 0.30, y0: 0.45, x1: 0.35, y1: 0.50 };
s.execute("photo.rotateRight", &json!({})).unwrap();
let r = s.face_job(id, face, 256).unwrap().settings.crop.geometry.rect;
// a quarter turn clockwise puts the left of the photo at the top: centre (0.325, 0.475) → (0.525, 0.325)
assert!((((r.x0 + r.x1) / 2.0) - 0.525).abs() < 1e-9 && (((r.y0 + r.y1) / 2.0) - 0.325).abs() < 1e-9, "{r:?}");
// the rotated photo is h × w pixels
let (pw, ph) = ((r.x1 - r.x0) * h, (r.y1 - r.y0) * w);
assert!((pw - ph).abs() < 1e-6 * w.max(h), "square in rotated pixels: {pw} × {ph}");
}
+26
View File
@@ -324,6 +324,32 @@ fn a_failed_export_write_keeps_the_previous_file() {
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn export_protects_the_target_of_an_imported_symlink() {
use crate::export::{Conflict, Destination};
let (original_session, _, dir, src, original) = library_with_jpeg("export-symlink-original");
drop(original_session);
let alias = dir.join("alias.jpg");
std::os::unix::fs::symlink(&src, &alias).unwrap();
let sidecar = dir.join("metadata.xmp");
std::fs::write(&sidecar, b"<x/>").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"<x/>");
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #134: exports can always be made again, so they are written atomically but not synced to
/// disk file by file; an edit copy that becomes a library photo still is.
#[test]
+19
View File
@@ -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");
+156
View File
@@ -173,6 +173,162 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
let _ = std::fs::remove_dir_all(&src);
}
const MWG_REGIONS: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
<mwg-rs:Regions>
<rdf:Description>
<mwg-rs:AppliedToDimensions stDim:w="100" stDim:h="100" stDim:unit="pixel"/>
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li>
<rdf:Description mwg-rs:Name="Jane Doe" mwg-rs:Type="Face">
<mwg-rs:Area stArea:x="0.5" stArea:y="0.5" stArea:w="0.3" stArea:h="0.4" stArea:unit="normalized"/>
</rdf:Description>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</rdf:Description>
</mwg-rs:Regions>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
/// A face region written by Lightroom (MWG-RS) into a sidecar reaches `Photo.meta.regions` on
/// import — the same path real catalogs use, exercised end to end rather than just at the parser.
#[test]
fn regions_from_sidecar_are_read_on_import() {
let src = temp_dir("regions");
write_png(&src.join("portrait.png"), 9);
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
let mut s = Session::new().with_fs();
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
assert_eq!(r["sidecars"], 1);
let p = s.catalog.photos().next().unwrap().clone();
assert_eq!(p.meta.regions.len(), 1);
let region = &p.meta.regions[0];
assert_eq!(region.name.as_deref(), Some("Jane Doe"));
assert_eq!(region.kind, lightcraft_meta::RegionKind::Face);
assert!((region.rect.width() - 0.3).abs() < 1e-9, "{:?}", region.rect);
let _ = std::fs::remove_dir_all(&src);
}
/// Removing a face box is a catalog-only, undoable edit: it never rewrites the sidecar (Lightroom's
/// regions and everything else in it stay byte for byte), even with auto-write on.
#[test]
fn removing_a_region_is_undoable_and_leaves_the_sidecar_alone() {
let src = temp_dir("region-remove");
write_png(&src.join("portrait.png"), 9);
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
let before = std::fs::read(src.join("portrait.xmp")).unwrap();
let n = s.catalog.photo(id).unwrap().meta.regions.len();
assert!(n >= 1);
assert!(s.execute("photo.removeRegion", &json!({"index": n})).is_err(), "out of range");
assert!(s.execute("photo.removeRegion", &json!({})).is_err(), "no index");
let r = s.execute("photo.removeRegion", &json!({"index": 0})).unwrap();
assert_eq!(r["removed"], "Jane Doe");
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n - 1);
assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched");
// an ordinary edit still auto-writes (the guard is per command)
s.execute("photo.rate", &json!({"rating": 4})).unwrap();
assert_ne!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "auto-write still works for other edits");
// undo brings the region back
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), n);
let _ = std::fs::remove_dir_all(&src);
}
/// Resizing a face box is a catalog-only, undoable edit with strict input handling; it leaves the
/// sidecar alone like removal does.
#[test]
fn resizing_a_region_validates_clamps_undoes_and_leaves_the_sidecar_alone() {
let src = temp_dir("region-resize");
write_png(&src.join("portrait.png"), 9);
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap();
let before = std::fs::read(src.join("portrait.xmp")).unwrap();
let original = s.catalog.photo(id).unwrap().meta.regions[0].rect;
let set = |s: &mut Session, rect: serde_json::Value| s.execute("photo.setRegion", &json!({"index": 0, "rect": rect}));
// hostile input is an error and changes nothing
for bad in [
json!({"x0": 0.1, "y0": 0.1, "x1": 0.1, "y1": 0.5}),
json!({"x0": 0.1, "y0": 0.1, "x1": 0.1001, "y1": 0.5}),
json!({"x0": 0.1, "y0": 0.1, "x1": 0.5}),
json!({"x0": "a", "y0": 0.1, "x1": 0.5, "y1": 0.5}),
json!({"x0": null, "y0": 0.1, "x1": 0.5, "y1": 0.5}),
] {
assert!(set(&mut s, bad.clone()).is_err(), "{bad}");
}
assert!(s.execute("photo.setRegion", &json!({"index": 9, "rect": {"x0": 0.1, "y0": 0.1, "x1": 0.5, "y1": 0.5}})).is_err(), "no such region");
assert!(s.execute("photo.setRegion", &json!({"index": 0})).is_err(), "no rect");
assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original);
// corners given in any order and outside the photo are clamped into it
let r = set(&mut s, json!({"x0": 0.6, "y0": 1.5, "x1": -0.2, "y1": 0.2})).unwrap();
assert_eq!(r["rect"], json!({"x0": 0.0, "y0": 0.2, "x1": 0.6, "y1": 1.0}));
let now = s.catalog.photo(id).unwrap().meta.regions[0].rect;
assert_eq!((now.x0, now.y0, now.x1, now.y1), (0.0, 0.2, 0.6, 1.0));
assert_eq!(std::fs::read(src.join("portrait.xmp")).unwrap(), before, "the sidecar is untouched");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().meta.regions[0].rect, original);
let _ = std::fs::remove_dir_all(&src);
}
/// Re-reading a sidecar: one that has `mwg-rs:Regions` is authoritative, so an emptied region list
/// clears the stale regions; one without `mwg-rs:Regions` (an app that doesn't do regions) leaves the
/// photo's regions alone.
#[test]
fn rereading_a_sidecar_clears_regions_only_when_it_states_them() {
let src = temp_dir("region-reread");
write_png(&src.join("portrait.png"), 9);
std::fs::write(src.join("portrait.xmp"), MWG_REGIONS).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
let id = s.active().unwrap();
assert_eq!(s.catalog.photo(id).unwrap().meta.regions.len(), 1);
// another app rewrote the sidecar without any notion of regions: they stay
std::fs::write(
src.join("portrait.xmp"),
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/>
</rdf:RDF></x:xmpmeta>"#,
)
.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#"<rdf:li>
<rdf:Description mwg-rs:Name="Jane Doe" mwg-rs:Type="Face">
<mwg-rs:Area stArea:x="0.5" stArea:y="0.5" stArea:w="0.3" stArea:h="0.4" stArea:unit="normalized"/>
</rdf:Description>
</rdf:li>"#,
"",
);
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<u8> {
synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default())
}
+3 -1
View File
@@ -163,7 +163,9 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
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}));
}
+30
View File
@@ -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) {
+5
View File
@@ -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") {
+344
View File
@@ -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<Cr3Track>,
}
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<Cr3<'_>> {
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<BoxRef> {
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<Cr3Track> {
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<u16> {
b.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]]))
}
fn be32(b: &[u8], at: usize) -> Option<u32> {
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<u64> {
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<Vec<u8>> {
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<u8> {
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<u8> {
let mut b = u.to_vec();
b.extend_from_slice(body);
bx(b"uuid", &b)
}
fn full(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut b = vec![0u8; 4];
b.extend_from_slice(body);
bx(kind, &b)
}
fn craw(w: u16, h: u16, children: &[u8]) -> Vec<u8> {
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<u8>, offset: u64, size: u32) -> Vec<u8> {
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<u8> {
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<u8> {
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"<x:xmpmeta/>"));
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"<x:xmpmeta/>".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()));
}
}
+37 -2
View File
@@ -7,10 +7,12 @@
//! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON).
//! - [`parse_iptc`] — IPTC-IIM record 2 datasets.
//! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time.
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw).
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3).
//! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks).
#![forbid(unsafe_code)]
mod container;
pub mod cr3;
mod datetime;
mod exif;
mod gpx;
@@ -24,7 +26,7 @@ pub use datetime::DateTime;
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
pub use iptc::parse_iptc;
pub use lightcraft_geom::Orientation;
pub use lightcraft_geom::{Orientation, Rect};
pub use tags::{TagRow, file_tag_rows, tag_rows};
pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, XmpValue, parse_xmp, write_xmp, write_xmp_lc};
pub use xmp_merge::{MergeRules, merge_xmp};
@@ -48,6 +50,31 @@ pub struct Flash {
pub raw: u16,
}
/// A named region of interest on a photo (MWG Region Guidelines, `mwg-rs:Regions`): most often a face,
/// drawn by Lightroom, digiKam, Picasa or similar tools. Read-only for now — LightCraft does not write
/// regions yet (see `docs/xmp-interop.md`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Region {
/// Normalized to the image's full, oriented frame (`Metadata::width`/`height`), y-down, 0..1 on
/// both axes — MWG's own convention, and what `AppliedToDimensions` areas are converted to.
pub rect: Rect,
pub kind: RegionKind,
/// `mwg-rs:Name` (e.g. the person's name).
pub name: Option<String>,
pub description: Option<String>,
}
/// `mwg-rs:Type`. Unrecognised values are kept verbatim rather than dropped.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RegionKind {
Face,
Pet,
Focus,
BarCode,
/// `mwg-rs:Type` was missing, empty, or a value we don't recognise.
Other(String),
}
/// Everything LightCraft shows or searches about a photo. All fields are optional; unknown = `None`/empty.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Metadata {
@@ -113,6 +140,8 @@ pub struct Metadata {
pub rating: Option<i8>,
/// Colour label name (e.g. "Red").
pub label: Option<String>,
/// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only.
pub regions: Vec<Region>,
}
macro_rules! fill {
@@ -173,6 +202,9 @@ impl Metadata {
if self.hierarchical_keywords.is_empty() {
self.hierarchical_keywords = other.hierarchical_keywords.clone();
}
if self.regions.is_empty() {
self.regions = other.regions.clone();
}
}
/// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with
@@ -185,6 +217,9 @@ impl Metadata {
if !other.hierarchical_keywords.is_empty() {
self.hierarchical_keywords = other.hierarchical_keywords.clone();
}
if !other.regions.is_empty() {
self.regions = other.regions.clone();
}
}
/// Exposure time formatted like cameras show it: `1/250`, `0.5`, `2″`.
+277 -1
View File
@@ -8,7 +8,8 @@
//! Writing emits the interchange subset in standard namespaces (dc, xmp, photoshop, exif, exifEX, tiff, lr) and
//! LightCraft's full develop settings as an opaque JSON string in `lc:settings`.
use crate::{DateTime, Flash, Gps, Metadata, Orientation, parse_number};
use crate::{DateTime, Flash, Gps, Metadata, Orientation, Region, RegionKind, parse_number};
use lightcraft_geom::{Point, Rect};
use quick_xml::escape::{escape, partial_escape};
use quick_xml::events::Event;
use std::collections::BTreeMap;
@@ -32,6 +33,11 @@ const NAMESPACES: &[(&str, &str)] = &[
("lc", LC_NS),
// Read-only: develop settings written by other raw developers (interchange; see docs/xmp-interop.md).
("crs", CRS_NS),
// Read-only: face/pet/focus regions written by Lightroom, digiKam, Picasa and others (MWG Region
// Guidelines v2.0). Canonicalising these three lets a packet use any prefix for them.
("mwg-rs", "http://www.metadataworkinggroup.com/schemas/regions/"),
("stArea", "http://ns.adobe.com/xmp/sType/Area#"),
("stDim", "http://ns.adobe.com/xap/1.0/sType/Dimensions#"),
];
/// The camera-raw-settings namespace URI (prefix `crs`), read for interchange only.
@@ -374,6 +380,72 @@ fn parse_gps_coord(s: &str) -> Option<f64> {
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<Region> {
// `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<Region> {
let area = item.field("mwg-rs:Area")?;
let num_attr = |k: &str| area.field(k).and_then(XmpValue::text).and_then(parse_number);
// stArea:x/y are the region's CENTER, not its top-left corner (MWG Region Guidelines §Area).
let (cx, cy, w, h) = (num_attr("stArea:x")?, num_attr("stArea:y")?, num_attr("stArea:w")?, num_attr("stArea:h")?);
let finite_and_sane = [cx, cy, w, h].iter().all(|v| v.is_finite() && v.abs() <= 1.0e6);
if !finite_and_sane || w <= 0.0 || h <= 0.0 {
return None;
}
// `stArea:unit` is "normalized" (a fraction of AppliedToDimensions) in every writer we know of, and
// that needs no cross-referencing at all. Only a region that explicitly says "pixel" gets divided
// down, and only if AppliedToDimensions is there to divide by; otherwise it's dropped rather than
// stored as a nonsense fraction.
let is_pixels = area.field("stArea:unit").and_then(XmpValue::text).is_some_and(|u| u.eq_ignore_ascii_case("pixel"));
let (cx, cy, w, h) = match (is_pixels, px_dims) {
(false, _) => (cx, cy, w, h),
(true, Some((dw, dh))) => (cx / dw, cy / dh, w / dw, h / dh),
(true, None) => return None,
};
// Lightroom Classic stores the box of a photo with Exif orientation 3 / 6 / 8 in the sensor's own
// frame and writes the orientation as the region's `mwg-rs:Rotation` (π for 3, −π/2 for 6, +π/2 for
// 8; 0 for 1) while `AppliedToDimensions` names the upright size. Turn it into the upright frame.
// MWG has no way to say "mirrored", so orientations 2/4/5/7 can't be told apart from their unmirrored
// twins here; boxes on mirrored photos are taken as written (see LR-LIB-PEOPLE in docs/parity.md).
let rotation = item.field("mwg-rs:Rotation").and_then(XmpValue::text).and_then(parse_number).filter(|r| r.is_finite());
let (quarter, half) = (std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
let (cx, cy, w, h) = match rotation {
Some(r) if (r + quarter).abs() < 0.05 => (1.0 - cy, cx, h, w),
Some(r) if (r - quarter).abs() < 0.05 => (cy, 1.0 - cx, h, w),
Some(r) if (r.abs() - half).abs() < 0.05 => (1.0 - cx, 1.0 - cy, w, h),
_ => (cx, cy, w, h),
};
// Keep only the part of the box inside the photo: a region hanging off an edge (common for faces
// cut by the frame) is clipped, and one entirely outside it (hostile or stale) is dropped, so every
// stored rect is within 0..1 and non-empty.
let rect = Rect::from_center(Point::new(cx, cy), w, h).intersect(&Rect::UNIT);
if rect.is_empty() {
return None;
}
let name = item.field("mwg-rs:Name").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty());
let description = item.field("mwg-rs:Description").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty());
let kind = match item.field("mwg-rs:Type").and_then(XmpValue::text).unwrap_or("") {
t if t.eq_ignore_ascii_case("face") => RegionKind::Face,
t if t.eq_ignore_ascii_case("pet") => RegionKind::Pet,
t if t.eq_ignore_ascii_case("focus") => RegionKind::Focus,
t if t.eq_ignore_ascii_case("barcode") => RegionKind::BarCode,
t => RegionKind::Other(t.to_string()),
};
Some(Region { rect, kind, name, description })
}
fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String {
let a = v.abs();
let d = a.trunc();
@@ -470,6 +542,7 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {
let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a });
m.gps = Some(Gps { latitude: lat, longitude: lon, altitude });
}
m.regions = values.get("mwg-rs:Regions").map(parse_regions).unwrap_or_default();
let lc_settings = props.get("lc:settings").and_then(|v| v.first()).cloned();
Ok(XmpData { metadata: m, lc_settings, properties: props, values })
}
@@ -610,6 +683,9 @@ mod tests {
fn full() -> Metadata {
Metadata {
// write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the
// roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it.
regions: Vec::new(),
make: Some("Maker & Sons".into()),
model: Some("X <1>".into()),
serial_number: Some("SN1".into()),
@@ -764,4 +840,204 @@ mod tests {
assert_eq!(parse_gps_coord("é"), None);
assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S");
}
/// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how
/// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one
/// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas
/// (the default, and the only unit real files use) don't need it.
#[test]
fn mwg_regions_element_form() {
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#"
xmlns:stDim="http://ns.adobe.com/xap/1.0/sType/Dimensions#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:AppliedToDimensions rdf:parseType="Resource" stDim:w="4000" stDim:h="3000" stDim:unit="pixel"/>
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3" stArea:unit="normalized"/>
<mwg-rs:Name>Jane Doe</mwg-rs:Name>
<mwg-rs:Type>Face</mwg-rs:Type>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.1" stArea:y="0.1" stArea:w="0.05" stArea:h="0.05" stArea:unit="normalized"/>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.8" stArea:y="0.2" stArea:w="0.1" stArea:h="0.1" stArea:unit="normalized"/>
<mwg-rs:Type>Pet</mwg-rs:Type>
<mwg-rs:Name>Rex</mwg-rs:Name>
<mwg-rs:Description>Good boy</mwg-rs:Description>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let regions = parse_xmp(x).unwrap().metadata.regions;
assert_eq!(regions.len(), 3, "{regions:?}");
assert_eq!(regions[0].kind, RegionKind::Face);
assert_eq!(regions[0].name.as_deref(), Some("Jane Doe"));
assert_eq!(regions[0].description, None);
let r = regions[0].rect;
assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}");
assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}");
// No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`.
assert_eq!(regions[1].kind, RegionKind::Other(String::new()));
assert_eq!(regions[1].name, None);
assert_eq!(regions[2].kind, RegionKind::Pet);
assert_eq!(regions[2].name.as_deref(), Some("Rex"));
assert_eq!(regions[2].description.as_deref(), Some("Good boy"));
}
/// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame
/// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the
/// upright frame.
#[test]
fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() {
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li rdf:parseType="Resource" mwg-rs:Rotation="-1.57080" mwg-rs:Type="Face">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.3" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3"/>
</rdf:li>
<rdf:li rdf:parseType="Resource" mwg-rs:Rotation="1.57080" mwg-rs:Type="Face">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.3" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3"/>
</rdf:li>
<rdf:li rdf:parseType="Resource" mwg-rs:Rotation="0.00000" mwg-rs:Type="Face">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.3" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3"/>
</rdf:li>
<rdf:li rdf:parseType="Resource" mwg-rs:Rotation="3.14159" mwg-rs:Type="Face">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.3" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3"/>
</rdf:li>
<rdf:li rdf:parseType="Resource" mwg-rs:Rotation="-3.14159" mwg-rs:Type="Face">
<mwg-rs:Area rdf:parseType="Resource" stArea:x="0.3" stArea:y="0.4" stArea:w="0.2" stArea:h="0.3"/>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
let regions = parse_xmp(x).unwrap().metadata.regions;
assert_eq!(regions.len(), 5, "{regions:?}");
let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9);
assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect);
assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect);
assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect);
assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect);
assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect);
}
/// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an
/// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an
/// unrecognised `mwg-rs:Type` value kept verbatim.
#[test]
fn mwg_regions_attribute_form_and_unknown_type() {
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li>
<rdf:Description mwg-rs:Type="BarCode">
<mwg-rs:Area stArea:x="0.3" stArea:y="0.3" stArea:w="0.1" stArea:h="0.1"/>
</rdf:Description>
</rdf:li>
<rdf:li>
<rdf:Description mwg-rs:Type="Sticker">
<mwg-rs:Area stArea:x="0.6" stArea:y="0.6" stArea:w="0.1" stArea:h="0.1"/>
</rdf:Description>
</rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li rdf:parseType="Resource"><mwg-rs:Name>No Area</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="NaN" stArea:y="0.1" stArea:w="0.1" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="1e300" stArea:y="0.1" stArea:w="0.1" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="0" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="-0.1" stArea:h="0.1"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="100" stArea:y="100" stArea:w="50" stArea:h="50" stArea:unit="pixel"/></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="0.2" stArea:h="0.2"/><mwg-rs:Type>Face</mwg-rs:Type></rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF>"#;
let regions = parse_xmp(x).unwrap().metadata.regions;
assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}");
assert_eq!(regions[0].kind, RegionKind::Face);
}
/// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a
/// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view
/// would then draw or crop far outside the image.
#[test]
fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() {
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:mwg-rs="http://www.metadataworkinggroup.com/schemas/regions/"
xmlns:stArea="http://ns.adobe.com/xmp/sType/Area#">
<mwg-rs:Regions rdf:parseType="Resource">
<mwg-rs:RegionList>
<rdf:Bag>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="1e5" stArea:y="0.5" stArea:w="0.1" stArea:h="0.1"/><mwg-rs:Name>Far right</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="-3" stArea:y="-3" stArea:w="0.5" stArea:h="0.5"/><mwg-rs:Name>Far left</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="1.2" stArea:y="0.5" stArea:w="0.2" stArea:h="0.2"/><mwg-rs:Name>Touching edge</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.5" stArea:y="0.5" stArea:w="1e5" stArea:h="1e5"/><mwg-rs:Name>Huge</mwg-rs:Name></rdf:li>
<rdf:li rdf:parseType="Resource"><mwg-rs:Area rdf:parseType="Resource" stArea:x="0.95" stArea:y="0.05" stArea:w="0.2" stArea:h="0.2"/><mwg-rs:Name>Corner</mwg-rs:Name></rdf:li>
</rdf:Bag>
</mwg-rs:RegionList>
</mwg-rs:Regions>
</rdf:Description></rdf:RDF>"#;
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 &regions {
let q = r.rect;
assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}");
}
assert_eq!(regions[0].rect, Rect::UNIT);
let c = regions[1].rect;
assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}");
}
/// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords).
#[test]
fn regions_merge_like_keywords() {
let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None };
let mut a = Metadata::default();
let b = Metadata { regions: vec![with_region.clone()], ..Default::default() };
a.fill_missing(&b);
assert_eq!(a.regions, vec![with_region.clone()]);
let mut a = Metadata::default();
a.overlay_user_fields(&b);
assert_eq!(a.regions, vec![with_region]);
}
}
+39 -1
View File
@@ -96,10 +96,20 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
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<u8> { [&((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);
}
}
+36 -3
View File
@@ -56,10 +56,17 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
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<Vec<u8>>, w: u32, h: u32, rps: u32) -> Vec<u8> {
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<Vec<u8>>, w: u32, h: u32, rps: u32, note: Option<IfdBuilder>) -> Vec<u8> {
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<u8> = (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);
+29
View File
@@ -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::<Vec<_>>()
};
// 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]
+1
View File
@@ -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 }
+96 -14
View File
@@ -1,18 +1,100 @@
//! Compile translated format strings so Rust checks both languages' placeholders.
use std::{collections::BTreeMap, env, fs, path::PathBuf};
//! Compile the translated format strings into the `tr_format!` macro.
//!
//! `locales/<code>-formats.json` maps an English format string to its translation for one language.
//! Every English message becomes one `tr_format!` arm that picks the active language and formats
//! that language's translation with `format!` — so Rust checks every catalog's placeholders against
//! the call site's arguments (a wrong or forgotten placeholder is a compile error, and `{:.1}`,
//! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means
//! adding its file (and its `language_table!` entry); this script picks the file up by name.
//!
//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog
//! does not compile, so a message missing from one language would otherwise go unnoticed until it
//! shows English.
use std::{
collections::{BTreeMap, BTreeSet},
env, fs,
path::PathBuf,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=locales/ja-formats.json");
let messages: BTreeMap<String, String> = serde_json::from_str(&fs::read_to_string("locales/ja-formats.json")?)?;
let mut source = String::from("macro_rules! tr_format {\n");
for (english, japanese) in messages {
let en = serde_json::to_string(&english)?;
let ja = serde_json::to_string(&japanese)?;
source.push_str(&format!(
"({en} $(, $($args:tt)*)?) => {{ if $crate::i18n::is_japanese() {{ format!({ja} $(, $($args)*)?) }} else {{ format!({en} $(, $($args)*)?) }} }};\n"
));
}
source.push_str("}\npub(crate) use tr_format;\n");
fs::write(PathBuf::from(env::var("OUT_DIR")?).join("ja-formats.rs"), source)?;
println!("cargo:rerun-if-changed=build.rs");
// The directory itself, so a catalog added or removed reruns this script.
println!("cargo:rerun-if-changed=locales");
let out = PathBuf::from(env::var("OUT_DIR")?);
fs::write(out.join("tr-formats.rs"), generate()?)?;
Ok(())
}
/// `ja` → `Ja`, `zh-hans` → `ZhHans`: the `Locale` variant the `language_table!` entry declares.
/// The code must be a lowercase BCP-47-like tag (`^[a-z]+(-[a-z]+)*$`), so the variant is always a
/// valid Rust identifier.
fn variant_for(code: &str) -> Result<String, String> {
let valid = !code.is_empty() && code.split('-').all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_lowercase()));
if !valid {
return Err(format!("locales/{code}-formats.json: the language code must match ^[a-z]+(-[a-z]+)*$"));
}
Ok(code.split('-').map(capitalize).collect())
}
fn capitalize(part: &str) -> String {
let mut chars = part.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn generate() -> Result<String, Box<dyn std::error::Error>> {
let mut catalogs: Vec<(String, String, BTreeMap<String, String>)> = 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<String, String> =
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)
}
+19 -1
View File
@@ -100,5 +100,23 @@
"… and {more} more": "…ほか{more}件",
"Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}",
"Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚",
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚"
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚",
" ({} in Recently Deleted)": "(うち{}枚は最近削除した写真)",
"Already in the library: {} photo{} selected in All Photos": "ライブラリに登録済み:すべての写真で{}枚を選択しました{:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し",
"Auto ({})": "自動 ({})",
"Build 1:1 Previews ({scope})": "1:1プレビューを作成({scope})",
"Build Smart Previews ({scope})": "スマートプレビューを作成({scope})",
"Build Standard-Sized Previews ({scope})": "標準プレビューを作成({scope})",
"Discard Smart Previews ({scope})": "スマートプレビューを破棄({scope})",
"Label Set: {name}": "ラベルセット:{name}",
"Quick Develop: {name}": "クイック現像: {name}",
"Reset {name}": "{name}をリセット",
"Updated “{name}”": "「{name}」を更新しました",
"{month}": "{month}月",
"{month} {year}": "{year}年{month}月",
"{verb} {n} Photo{}": "{n}枚を{verb}{:.0}",
"{weekday}, {day}": "{day}日({weekday})",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日({weekday})",
"{year}": "{year}年"
}
+11 -1
View File
@@ -1017,5 +1017,15 @@
"Quit": "終了",
"Temporary session — your library isn't open, and nothing you do here is saved.": "一時セッション:ライブラリが開かれていないため、ここでの操作は保存されません。",
"Back Up Library…": "ライブラリをバックアップ…",
"Restore Library from Backup…": "バックアップからライブラリを復元…"
"Restore Library from Backup…": "バックアップからライブラリを復元…",
"In Recently Deleted": "最近削除した写真内",
"Reload from Disk": "ディスクから再読み込み",
"Monday": "月曜日",
"Tuesday": "火曜日",
"Wednesday": "水曜日",
"Thursday": "木曜日",
"Friday": "金曜日",
"Saturday": "土曜日",
"Sunday": "日曜日",
"Unknown Date": "日付不明"
}
+122
View File
@@ -0,0 +1,122 @@
{
" · {skipped} skipped (file exists)": " · 已跳过 {skipped} 个(文件已存在)",
" · {} duplicate{} skipped": " · 已跳过 {0} 个重复项{1:.0}",
" · {} failed: {}": " · {0} 个失败:{1}",
" · {} moved": " · 已移动 {0} 个",
" · {} not readable": " · {0} 个无法读取",
", {failed} file{} not readable": ",{failed} 个文件无法读取{0:.0}",
"Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片",
"Add {} Photo{}": "添加 {0} 张照片{1:.0}",
"Add “{}” to Favorites": "将“{0}”添加到收藏夹",
"Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}",
"Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}",
"All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。",
"Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片",
"Automatic ({} MB)": "自动({0} MB)",
"Click {}:1": "单击 {0}:1",
"Create {n} Virtual Copies": "创建 {n} 个虚拟副本",
"Decoder: {reason}": "解码器:{reason}",
"Delete \"{label}\"": "删除 \"{label}\"",
"Delete \"{label}\" (and the mask)": "删除 \"{label}\"(及蒙版)",
"Delete {n} Photos": "删除 {n} 张照片",
"Delete “{current}”": "删除“{current}”",
"Duplicate \"{label}\"": "复制 \"{label}\"",
"Example: {example}": "示例:{example}",
"Exported {n} photo{}": "已导出 {n} 张照片{0:.0}",
"Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片{0:.0}",
"Exported {} point curve preset{}": "已导出 {0} 个点曲线预设{1:.0}",
"Exported {} preset{}": "已导出 {0} 个预设{1:.0}",
"Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张",
"Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选{0:.0}",
"Healed {n} dust spot{}": "已修复 {n} 处尘点{0:.0}",
"Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设{0:.0}",
"Imported {n} preset{}": "已导入 {n} 个预设{0:.0}",
"Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取{0:.0}{1:.0}",
"Imported {} photo{}": "已导入 {0} 张照片{1:.0}",
"Matches: {}": "匹配项:{0}",
"Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%",
"Move {what} to Recently Deleted?": "将 {what} 移到最近删除?",
"Named ({named_n})": "已命名({named_n})",
"Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}",
"Preset: {name}": "预设:{name}",
"Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({0})",
"Preview only — {}": "仅预览 — {0}",
"Profile: {}": "配置文件:{0}",
"Proof Copy ({})": "校样副本({0})",
"Proof Preview · {}": "校样预览 · {0}",
"Quick Develop · {n} photos": "快速编辑 · {n} 张照片",
"Rated {}": "已评级 {0}",
"Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张",
"Redo {}": "重做:{0}",
"Remove “{}” from Favorites": "从收藏夹中移除“{0}”",
"Rename {n} Photos…": "重命名 {n} 张照片…",
"Rename “{name}”": "重命名“{name}”",
"Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}",
"Reset {} Channel": "重置 {0} 通道",
"Saved export preset “{}”": "已保存导出预设“{0}”",
"Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}",
"Showing {n} of {}": "显示 {0} 张中的 {n} 张",
"Smart album: {}": "智能相册:{0}",
"Undo {}": "撤销 {0}",
"Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {0} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。",
"Version: {}": "版本:{0}",
"none (CPU rendering): {why}": "无(CPU 渲染):{why}",
"the {n} selected photos": "选中的 {n} 张照片",
"the {} photos in view": "视图中的 {0} 张照片",
"these {count} photos": "这 {count} 张照片",
"{count} smart preview{} · {:.1} MB": "{count} 个智能预览{0:.0} · {1:.1} MB",
"{f} folders (and their subfolders)": "{f} 个文件夹(及其子文件夹)",
"{label} Grading": "{label} 颜色分级",
"{label} {d:+} on every selected photo": "每张选中照片的 {label} {d:+}",
"{label}, {n} photos": "{label},{n} 张照片",
"{matching} of {t} photos": "{t} 张照片中的 {matching} 张",
"{matching} photos": "{matching} 张照片",
"{mode} spot {} of {}": "{mode}:第 {0} 个污点,共 {1} 个",
"{msg} ({failed} failed)": "{msg}({failed} 项失败)",
"{name} Label": "{name} 标签",
"{name} Off": "已关闭 {name}",
"{name} On": "已开启 {name}",
"{name} — double-click to reset it": "{name} — 双击可重置",
"{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 项更改已保存在内存中,但尚未写入磁盘:{e}\nLightCraft 会自动重试;现在退出将丢失这些更改。{0:.0}{1:.0}",
"{n} found · {sel} selected": "找到 {n} 张 · 已选 {sel} 张",
"{n} photos": "{n} 张照片",
"{n} photos in {} are hidden by the filters above": "{0} 中的 {n} 张照片被上方的过滤器隐藏",
"{n} photo{}": "{n} 张照片{0:.0}",
"{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新{0:.0}",
"{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新{0:.0}",
"{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}",
"{stage}… {:.0}%": "{stage}… {0:.0}%",
"{what} merge added": "已添加 {what} 合并",
"{what} merge failed: {e}": "{what} 合并失败:{e}",
"{} and higher": "{0} 及以上",
"{} files": "{0} 个文件",
"{} files and {f} folder{}": "{0} 个文件和 {f} 个文件夹{1:.0}",
"{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {0} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。",
"{} photos": "{0} 张照片",
"{} photo{} not readable": "{0} 张照片无法读取{1:.0}",
"{} spot(s) on this photo": "此照片上有 {0} 处污点",
"· {dups} already in the library": "· {dups} 张已在图库中",
"· {n} photo{}": "· {n} 张照片{0:.0}",
"… and {more} more": "…以及另外 {more} 项",
"Import cancelled · {} photo{} added": "导入已取消 · 已添加 {0} 张照片{1:.0}",
"Reading photos… {} of {}": "正在读取照片… 第 {0} 张,共 {1} 张",
"Adding photos… {} of {}": "正在添加照片… 第 {0} 张,共 {1} 张",
" ({} in Recently Deleted)": "(其中 {0} 张在“最近删除”中)",
"Already in the library: {} photo{} selected in All Photos": "已在图库中:已在“所有照片”中选择 {0} 张照片{1:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入",
"Auto ({})": "自动({})",
"Build 1:1 Previews ({scope})": "生成 1:1 预览({scope})",
"Build Smart Previews ({scope})": "生成智能预览({scope})",
"Build Standard-Sized Previews ({scope})": "生成标准尺寸预览({scope})",
"Discard Smart Previews ({scope})": "丢弃智能预览({scope})",
"Label Set: {name}": "标签集:{name}",
"Quick Develop: {name}": "快速编辑:{name}",
"Reset {name}": "重置{name}",
"Updated “{name}”": "已更新“{name}”",
"{verb} {n} Photo{}": "{verb} {n} 张照片{:.0}",
"{year}": "{year}年",
"{month}": "{month}月",
"{month} {year}": "{year}年{month}月",
"{weekday}, {day}": "{day}日 {weekday}",
"{weekday}, {day} {month} {year}": "{year}年{month}月{day}日 {weekday}"
}
File diff suppressed because it is too large Load Diff
+122
View File
@@ -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} · 按右鍵 ▸ 還原,或永久刪除後重新匯入"
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -55,7 +55,7 @@ fn wrap(r: Result<Value, String>) -> Outcome {
pub fn all_commands(app: &LightcraftApp) -> Value {
let mut v: Vec<Value> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
for (id, label, sc, menu) in crate::menus::UI_COMMANDS {
for (id, label, sc, menu) in crate::menus::ui_commands() {
v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true}));
}
Value::Array(v)
@@ -267,6 +267,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
u.dialog = app.ui.dialog.clone();
u.status = app.ui.status.clone();
app.ui = u;
crate::i18n::set_language(app.ui.language);
ctx.request_repaint();
ok(serde_json::to_value(&app.ui).unwrap_or_default())
}
+81
View File
@@ -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<String> {
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]
+631 -125
View File
@@ -1,69 +1,274 @@
//! Presentation-only localization. Command ids, user names and catalog data remain unchanged.
use std::{cell::Cell, collections::BTreeMap, sync::OnceLock};
//!
//! A language is one entry in [`language_table!`] below: its BCP-47 code, the endonym shown in the
//! Language menus, the ISO 15924 script its text needs (which picks the CJK font fallback), and its
//! message catalog (`locales/<code>.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<String, String> {
static CATALOGS: OnceLock<BTreeMap<Locale, BTreeMap<String, String>>> = OnceLock::new();
static EMPTY: OnceLock<BTreeMap<String, String>> = 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<Self> {
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<Self> {
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/<code>.json`).
language_table! {
En, "en";
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
}
// The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a
// language can be renamed in code without invalidating anyone's settings.
impl Serialize for Locale {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.code())
}
pub fn parse(code: &str) -> Option<Self> {
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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<Language> = const { Cell::new(Language::En) };
static LOCALE: std::cell::Cell<Locale> = const { std::cell::Cell::new(Locale::En) };
}
pub fn default_language() -> Language {
if std::env::var("LIGHTCRAFT_LANGUAGE").as_deref() == Ok("ja") { Language::Ja } else { Language::En }
/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs.
pub fn default_language() -> Locale {
std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En)
}
pub fn set_language(language: Language) {
LANGUAGE.with(|value| value.set(language));
pub fn set_language(language: Locale) {
LOCALE.with(|value| value.set(language));
}
pub fn is_japanese() -> bool {
LANGUAGE.with(|value| value.get() == Language::Ja)
}
fn japanese() -> &'static BTreeMap<String, String> {
static MESSAGES: OnceLock<BTreeMap<String, String>> = OnceLock::new();
MESSAGES.get_or_init(|| {
serde_json::from_str(include_str!("../locales/ja.json")).unwrap_or_else(|error| {
log::error!("Invalid Japanese message catalog: {error}");
BTreeMap::new()
})
})
pub fn language() -> Locale {
LOCALE.with(std::cell::Cell::get)
}
/// Translate a built-in display label, preserving unknown labels verbatim.
/// Never call this on editable user text, filenames or command identifiers.
pub fn tr(source: &str) -> &str {
if is_japanese() { japanese().get(source).map(String::as_str).unwrap_or(source) } else { source }
tr_in(language(), source)
}
/// Catalog values are `'static` (they live in the embedded file), so a translation can be handed
/// out for as long as the `'static` key it was looked up by.
fn tr_in(language: Locale, source: &str) -> &str {
if language.catalog().is_empty() {
return source;
}
thread_local! {
static VERBATIM: RefCell<BTreeMap<Locale, BTreeMap<String, &'static str>>> = 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<String, &'static str> {
language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect()
}
// `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from
// `locales/*-formats.json`.
include!(concat!(env!("OUT_DIR"), "/tr-formats.rs"));
/// A stock preset's or profile's name in the UI language; an imported or user-edited name stays
/// exactly as its owner wrote it.
pub fn builtin_label(source: &str, builtin: bool) -> &str {
if builtin { tr(source) } else { source }
}
/// An Activity (history) step for display. The step's stored label stays English; generated steps
/// are translated around the names they carry, and a user preset's name is kept verbatim.
pub fn history_label(source: &str, presets: &[lightcraft_develop::Preset]) -> String {
if let Some(name) = source.strip_prefix("Preset: ") {
// A user preset may share a stock preset's name: then the step could be either, so it
// keeps the name as written.
let builtin = presets.iter().any(|p| p.builtin && p.name == name) && !presets.iter().any(|p| !p.builtin && p.name == name);
return tr_format!("Preset: {name}", name = builtin_label(name, builtin));
}
if let Some(name) = source.strip_prefix("Reset ") {
return tr_format!("Reset {name}", name = tr(name));
}
if let Some(name) = source.strip_prefix("Quick Develop: ") {
return tr_format!("Quick Develop: {name}", name = tr(name));
}
tr(source).to_string()
}
/// A library source's heading (All Photos, Recently Deleted…) in the UI language; an album's
/// name is the user's and stays verbatim.
pub fn source_label(source: lightcraft_engine::LibrarySource, catalog: &lightcraft_catalog::Catalog) -> String {
let label = source.label(catalog);
if matches!(source, lightcraft_engine::LibrarySource::Album(id) if catalog.album(id).is_some()) { label } else { tr(&label).to_string() }
}
/// A date group heading (`2026-09-20`, `2026-09`, `2026`) in the UI language: the grid's full
/// form, or the date sidebar's `short` one ("Sunday, 20" / "September" / "2026"). English keeps the
/// catalog's own wording; every other language formats it with its `*-formats.json` date
/// patterns and weekday names. The grouping keys themselves never change.
pub fn date_group_label(key: &str, short: bool) -> String {
let label = lightcraft_catalog::dates::group_label(key);
if language() == Locale::En {
return if short {
match key.len() {
// "September 2026" → "September"
7 => label.split(' ').next().unwrap_or(&label).to_string(),
// "Sunday, 20 September 2026" → "Sunday, 20"
10 => label.rsplitn(3, ' ').nth(2).unwrap_or(&label).to_string(),
_ => label,
}
} else {
label
};
}
let Some(year) = key.get(..4).and_then(|y| y.parse::<u32>().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::<u32>().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::<u32>().ok())
{
let weekday = tr(weekday);
return if short {
tr_format!("{weekday}, {day}", weekday = weekday, day = day)
} else {
tr_format!("{weekday}, {day} {month} {year}", weekday = weekday, day = day, month = month, year = year)
};
}
tr(&label).to_string()
}
/// A capture time for display in the UI language (English: "March 30, 2022 at 10:11:11 PM");
/// the metadata itself is never rewritten, and a value that doesn't parse is shown as it is.
pub fn display_time(iso: &str) -> String {
if language() == Locale::En || lightcraft_catalog::dates::normalize_iso(iso).is_none() {
return lightcraft_catalog::dates::display_time(iso);
}
let date = date_group_label(iso.get(..10).unwrap_or(iso), false);
match iso.get(11..).filter(|time| !time.is_empty()) {
Some(time) => format!("{date} {time}"),
None => date,
}
}
#[cfg(test)]
@@ -71,53 +276,239 @@ mod tests {
use super::*;
#[test]
fn catalog_is_valid_and_contains_core_workflows() {
let messages: BTreeMap<String, String> = serde_json::from_str(include_str!("../locales/ja.json")).unwrap();
for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] {
assert!(messages.get(key).is_some_and(|value| !value.is_empty() && value != key), "{key}");
fn every_language_has_a_catalog_covering_the_source() {
assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog");
for language in Locale::ALL {
assert!(!language.name().is_empty(), "{language:?} has a name");
assert!(!language.script().is_empty(), "{language:?} has a script");
assert_eq!(Locale::parse(language.code()), Some(*language), "{language:?} parses its own code");
if *language == Locale::En {
continue;
}
let messages = language.catalog();
assert!(!messages.is_empty(), "{language:?}: {} has no messages", language.code());
for (key, value) in messages {
assert!(!value.is_empty(), "{language:?}: empty translation for {key:?}");
}
}
}
/// Where two catalogs translate the same text they agree on its placeholders. Catalogs may cover
/// different sets of messages (a message a language lacks shows in English): the gaps are
/// reported, not failed, so a translation can grow at its own pace.
#[test]
fn language_switches_and_unknown_text_survives() {
set_language(Language::Ja);
assert_eq!(tr("Exposure"), "露出");
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
assert_eq!(tr("develop.set"), "develop.set");
assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー");
set_language(Language::En);
assert_eq!(tr("Exposure"), "Exposure");
fn catalogs_agree_on_placeholders_and_report_gaps() {
let fields = |text: &str| {
let mut out: Vec<String> = 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> = Locale::ALL.iter().copied().filter(|language| *language != Locale::En).collect();
let all: std::collections::BTreeSet<&String> = translated.iter().flat_map(|language| language.catalog().keys()).collect();
for language in &translated {
let catalog = language.catalog();
for (key, value) in catalog {
assert_eq!(fields(key), fields(value), "{language:?}: {key:?} -> {value:?}");
}
let missing: Vec<&&String> = all.iter().filter(|key| !catalog.contains_key(key.as_str())).collect();
if !missing.is_empty() {
eprintln!("{} lacks {} message(s) another language translates (shown in English): {missing:?}", language.code(), missing.len());
}
}
}
/// Locale tags are matched leniently, so a system locale reaches a shipped language.
#[test]
fn locale_tags_normalize() {
assert_eq!(Locale::parse_tag("en"), Some(Locale::En));
assert_eq!(Locale::parse_tag("en-US"), Some(Locale::En));
assert_eq!(Locale::parse_tag("zh-hans"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("zh"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("zh_CN"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("zh-hant"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-Hant"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-TW"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh_TW.UTF-8"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-HK"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-Hant-TW"), Some(Locale::ZhHant));
assert_eq!(Locale::parse_tag("zh-CN"), Some(Locale::ZhHans));
assert_eq!(Locale::parse_tag("ja_JP.UTF-8"), Some(Locale::Ja));
assert_eq!(Locale::parse_tag("de"), None);
}
#[test]
fn translated_formats_preserve_counts_and_remove_english_plural_suffixes() {
set_language(Language::Ja);
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12枚");
fn switching_language_translates_and_restores() {
set_language(Locale::ZhHans);
assert_eq!(tr("Exposure"), "曝光");
assert_eq!(tr("Settings"), "设置");
// Untranslated and data-like text passes through untouched.
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
assert_eq!(tr("develop.set"), "develop.set");
assert_eq!(tr("A string no catalog has"), "A string no catalog has");
set_language(Locale::Ja);
assert_eq!(tr("Exposure"), "露出");
set_language(Locale::En);
assert_eq!(tr("Exposure"), "Exposure");
}
/// Format strings carry their values in every language, and a language without a translation
/// falls back to the English format rather than dropping the values.
#[test]
fn formats_render_in_every_language() {
// Every language's format string is checked by `format!` against the call site's values.
set_language(Locale::En);
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos");
set_language(Locale::ZhHans);
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "已导入 12 张照片");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已导出 12 张中的 4 张照片");
set_language(Locale::Ja);
assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました");
set_language(Language::En);
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 photos");
// Format specs survive translation: precision, sign, and values a language reorders.
for language in Locale::ALL {
set_language(*language);
let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3);
assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}");
let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning");
assert!(merging.contains("42%"), "{language:?}: {merging}");
let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5);
assert!(delta.contains("+5"), "{language:?}: {delta}");
let reading = tr_format!("Reading photos… {} of {}", 3, 10);
let (three, ten) = (reading.find('3'), reading.find("10"));
assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}");
}
set_language(Locale::En);
assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10");
}
/// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation
/// never shows as boxes. Without craft-fonts there are no CJK faces at all, and the CJK
/// catalogs are skipped (the same degradation the Japanese UI has always had).
#[test]
fn catalog_characters_are_paintable() {
let ctx = egui::Context::default();
ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS));
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
for language in Locale::ALL {
let messages = language.catalog();
if messages.is_empty() {
continue;
}
// The faces this language's script needs; a language the craft-fonts input does not
// cover (a translation added ahead of its font) is reported, not failed.
let mut faces: Vec<&str> =
lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect();
faces.dedup();
if faces.is_empty() {
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
continue;
}
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family.clone());
let mut missing: Vec<char> = 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::<String>()
);
}
});
}
}
/// The interface's own symbols paint in every language: the CJK faces stop at Han and kana, so a
/// translation must not carry a symbol no installed font has (egui's defaults cover the rest).
#[test]
fn interface_symbols_are_paintable() {
let ctx = egui::Context::default();
ctx.set_fonts(crate::theme::font_definitions(lightcraft_engine::CRAFT_FONTS));
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
out.textures_delta.clear();
// ⌥ and ⌫ come from egui's bundled fonts; ▸ and ▾ are in neither (measured 2026-10-07).
ctx.fonts_mut(|fonts| {
for symbol in ['⌥', '⌫', '⇧', '⌘', '→', '…', '—', '“', '”', '·', '»', '⌄', '★'] {
let font = egui::FontId::new(13.0, egui::FontFamily::Proportional);
assert!(fonts.has_glyph(&font, symbol), "{symbol:?} has no glyph in any installed font");
}
});
}
/// The language menu covers every language, and a language's own command selects it.
#[test]
fn language_commands_cover_every_language() {
let commands = [
("app.language.traditionalChinese", Locale::ZhHant),
("app.language.english", Locale::En),
("app.language.simplifiedChinese", Locale::ZhHans),
("app.language.japanese", Locale::Ja),
];
// One command per language, and every command reachable from the menu table.
assert_eq!(commands.len(), Locale::ALL.len());
for (id, language) in commands {
assert_eq!(crate::menus::language_from_command(id), Some(language), "{id}");
assert!(crate::menus::ui_commands().any(|command| command.0 == id), "{id} is not in the menu");
}
assert_eq!(crate::menus::language_from_command("app.language.klingon"), None);
assert_eq!(crate::menus::language_from_command("view.detail"), None);
}
#[test]
fn preferences_round_trip_and_old_settings_remain_readable() {
let old: crate::state::UiState = serde_json::from_str("{}").unwrap();
assert_eq!(old.language, Language::En);
let settings = crate::state::UiState { language: Language::Ja, ..old };
assert_eq!(old.language, Locale::En);
// Settings written before the language list grew still load.
let legacy: crate::state::UiState = serde_json::from_str(r#"{"language":"ja"}"#).unwrap();
assert_eq!(legacy.language, Locale::Ja);
let settings = crate::state::UiState { language: Locale::ZhHans, ..old };
let saved = serde_json::to_string(&settings).unwrap();
let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, Language::Ja);
assert_eq!(restored.language, Locale::ZhHans);
}
#[test]
fn japanese_is_painted_and_both_font_weights_cover_the_catalog() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.language = Language::Ja;
app.ui.left_panel = true;
let mut text = String::new();
fn catalogs_contain_core_workflows() {
for language in Locale::ALL.iter().filter(|language| **language != Locale::En) {
for key in ["Import Photos…", "Export…", "Exposure", "White Balance", "Settings", "Language"] {
let value = language.tr(key);
assert!(!value.is_empty() && value != key, "{language:?}: {key}");
}
}
}
/// User-named menu items (presets, albums, label sets) are never translated; built-in labels are.
#[test]
fn menu_labels_translate_but_user_names_survive() {
for language in Locale::ALL {
set_language(*language);
assert_eq!(crate::menubar::display_item_label("album.addPhotos", &serde_json::json!({"id": 1}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("app.export", &serde_json::json!({"preset": "Color"}), "Color"), "Color");
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), language.tr("Color"));
}
set_language(Locale::ZhHans);
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "颜色");
set_language(Locale::Ja);
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "カラー");
set_language(Locale::En);
}
/// The text the whole window paints over a few frames in `language`.
fn painted_text(ctx: &egui::Context, app: &mut crate::LightcraftApp, language: Locale) -> String {
fn collect(shape: &egui::epaint::Shape, text: &mut String) {
match shape {
egui::epaint::Shape::Text(shape) => {
@@ -128,41 +519,49 @@ mod tests {
_ => {}
}
}
app.ui.language = language;
let mut text = String::new();
for frame in 0..4 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1600.0, 1000.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
// This assertion inspects shapes without a renderer; discard texture uploads explicitly.
// This inspects shapes without a renderer; discard texture uploads explicitly.
out.textures_delta.clear();
text.clear();
for shape in out.shapes {
collect(&shape.shape, &mut text);
}
}
assert!(text.contains("マイフォト"), "{text}");
assert!(text.contains("すべての写真"), "{text}");
if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() {
eprintln!("skipped glyph coverage: built without CRAFT_FONTS_DIR, so there is no Japanese UI font");
} else {
ctx.fonts_mut(|fonts| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] {
let font = egui::FontId::new(13.0, family);
for message in japanese().values() {
for ch in message.chars().filter(|ch| !ch.is_whitespace()) {
assert!(fonts.has_glyph(&font, ch), "Missing glyph {ch} in {message}");
}
}
}
});
}
// Locale affects presentation only: command ids remain the same.
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
let japanese_ids = ids(&app);
set_language(Language::En);
assert_eq!(japanese_ids, ids(&app));
text
}
/// Every language is painted by the real window, switching at runtime reinstalls the fonts
/// (their CJK fallback order follows the language), and command ids never change with it.
#[test]
fn every_language_is_painted_and_menu_ids_stay_the_same() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.left_panel = true;
let ids = |app: &crate::LightcraftApp| crate::menus::menu_entries(app).into_iter().map(|entry| entry.id).collect::<Vec<_>>();
let english_ids = ids(&app);
for language in Locale::ALL {
let text = painted_text(&ctx, &mut app, *language);
for label in ["My Photos", "All Photos"] {
assert!(text.contains(language.tr(label)), "{language:?}: {label} -> {:?} not in\n{text}", language.tr(label));
}
assert_eq!(app.font_language, *language, "the fonts follow the language");
assert_eq!(ids(&app), english_ids, "{language:?}: command ids are presentation-independent");
}
let text = painted_text(&ctx, &mut app, Locale::Ja);
assert!(text.contains("マイフォト") && text.contains("すべての写真"), "{text}");
let text = painted_text(&ctx, &mut app, Locale::ZhHans);
assert!(text.contains("我的照片") && text.contains("所有照片"), "{text}");
set_language(Locale::En);
}
/// The UI families, after one frame so the font definitions are loaded.
fn fonts_ctx(craft: &'static [lightcraft_engine::CraftFont]) -> egui::Context {
let ctx = egui::Context::default();
@@ -172,31 +571,36 @@ mod tests {
ctx
}
fn ui_families() -> [egui::FontFamily; 3] {
[egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace]
}
/// Built with craft-fonts, Japanese renders with real glyphs (no tofu) in every UI family.
/// Built with craft-fonts, every CJK language renders with real glyphs (no tofu) in every UI
/// family, Monospace included.
#[test]
fn craft_fonts_render_japanese_in_the_ui() {
if lightcraft_engine::fonts::japanese(lightcraft_engine::CRAFT_FONTS).next().is_none() {
eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese UI font");
return;
}
let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS);
ctx.fonts_mut(|fonts| {
for family in ui_families() {
let font = egui::FontId::new(13.0, family);
for ch in "日本語の文字".chars() {
assert!(fonts.has_glyph(&font, ch), "{ch} in {font:?}");
}
let galley = fonts.layout_no_wrap("日本語の文字".into(), font.clone(), egui::Color32::WHITE);
assert!(galley.size().x > 13.0 * 5.0, "{font:?}: six full-width glyphs, {:?}", galley.size());
fn craft_fonts_render_cjk_in_the_ui() {
let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")];
for (language, sample) in samples {
if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) {
eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script());
continue;
}
});
set_language(language);
let ctx = fonts_ctx(lightcraft_engine::CRAFT_FONTS);
ctx.fonts_mut(|fonts| {
for family in
[egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into()), egui::FontFamily::Monospace]
{
let font = egui::FontId::new(13.0, family);
for ch in sample.chars() {
assert!(fonts.has_glyph(&font, ch), "{language:?}: {ch} in {font:?}");
}
let galley = fonts.layout_no_wrap(sample.into(), font.clone(), egui::Color32::WHITE);
let wide = 13.0 * (sample.chars().count() as f32 - 1.0);
assert!(galley.size().x > wide, "{language:?} {font:?}: full-width glyphs, {:?}", galley.size());
}
});
}
set_language(Locale::En);
}
/// Built without craft-fonts, the UI (in Japanese, too) still installs its fonts and runs;
/// Built without craft-fonts, the UI (in every language) still installs its fonts and runs;
/// Latin text keeps Inter.
#[test]
fn the_ui_works_without_craft_fonts() {
@@ -210,15 +614,117 @@ mod tests {
}
});
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.ui.language = Language::Ja;
for frame in 0..3 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
for language in Locale::ALL {
app.ui.language = *language;
for frame in 0..3 {
let input = crate::headless::HeadlessView::raw_input(egui::vec2(1200.0, 800.0), 1.0, frame as f64 / 60.0, vec![]);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
}
set_language(Language::En);
set_language(Locale::En);
}
/// Traditional Chinese through the menu command and the control channel, persisted, with
/// localised headings and dates; ids and user text are untouched.
#[test]
fn traditional_chinese_switches_through_menu_and_control_and_persists() {
use crate::control::{ControlRequest, Outcome};
let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default());
app.run("app.language.traditionalChinese", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Locale::ZhHant);
// Applied at once, not on the next frame.
assert_eq!(tr("File"), "檔案");
assert_eq!(source_label(lightcraft_engine::LibrarySource::All, &app.session.catalog), "所有照片");
assert_eq!(date_group_label("2026-09-20", false), "2026年9月20日 星期日");
assert_eq!(date_group_label("2026-09-20", true), "20日 星期日");
assert_eq!(date_group_label("2026-09", false), "2026年9月");
assert_eq!(date_group_label("2026-09", true), "9月");
assert_eq!(date_group_label("2026", false), "2026年");
assert_eq!(date_group_label("", false), "未知日期");
assert_eq!(display_time("2026-09-20T16:04:05"), "2026年9月20日 星期日 16:04:05");
assert_eq!(display_time("a-user-value"), "a-user-value");
assert_eq!(crate::menubar::checked(&app, "app.language.traditionalChinese"), Some(true));
assert_eq!(crate::menubar::checked(&app, "app.language.english"), Some(false));
assert_eq!(tr("my-photo.jpg"), "my-photo.jpg");
for id in ["album.addPhotos", "metadata.applyPreset", "label.applySet"] {
assert_eq!(crate::menubar::display_item_label(id, &serde_json::json!({"id": 1}), "Color"), "Color");
}
assert_eq!(crate::menubar::display_item_label("view.photoGrid", &serde_json::Value::Null, "Color"), "色彩");
assert_eq!(tr_format!("{n} photo{}", "s", n = 12), "12 張照片");
assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "已匯出 4/12 張照片");
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains(r#""language":"zh-hant""#), "{saved}");
assert_eq!(serde_json::from_str::<crate::state::UiState>(&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::<Vec<_>>();
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);
}
}
+10
View File
@@ -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)]);
+52 -1
View File
@@ -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<u64>,
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<u64> {
let mut v: Vec<u64> =
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);
+36 -2
View File
@@ -1,7 +1,7 @@
//! LightCraft's egui frontend: a Lightroom-style UI over `lightcraft-engine`.
//!
//! The UI is thin: every action goes through [`LightcraftApp::run`], which handles UI commands
//! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine.
//! (views, panels, zoom — see [`menus::ui_commands`]) and forwards everything else to the engine.
//! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -157,6 +157,9 @@ pub struct LightcraftApp {
/// Clear `synthetic_mods` on the next frame.
synthetic_mods_release: bool,
styled: bool,
/// The language the installed fonts were built for: the CJK fallback order follows the UI
/// language's script, so switching language reinstalls them.
font_language: i18n::Locale,
fonts_ready: bool,
last_time: f64,
/// Rect of the photo canvas and the displayed image (screen points) from the last frame.
@@ -223,6 +226,7 @@ impl LightcraftApp {
synthetic_mods: egui::Modifiers::NONE,
synthetic_mods_release: false,
styled: false,
font_language: i18n::Locale::En,
fonts_ready: false,
last_time: 0.0,
canvas_rect: None,
@@ -344,8 +348,13 @@ impl LightcraftApp {
}
pub fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>) {
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<String>, secs: f64) {
let t = ctx.input(|i| i.time);
self.ui.toast = Some((text.into(), t + 1.4));
self.ui.toast = Some((text.into(), t + secs));
}
fn drain_control(&mut self, ctx: &egui::Context) {
@@ -497,6 +506,12 @@ impl LightcraftApp {
let repaint = ctx.clone();
self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint()));
self.styled = true;
self.font_language = self.ui.language;
} else if self.font_language != self.ui.language {
// Shared Han characters take the active language's forms (Japanese faces for 日本語,
// the Simplified Chinese face for 简体中文): rebuild the fallback order.
theme::install_fonts(ctx);
self.font_language = self.ui.language;
} else {
self.fonts_ready = true;
}
@@ -713,6 +728,7 @@ impl LightcraftApp {
state::ViewMode::Compare => panels::compare::show_compare(self, ui),
state::ViewMode::Survey => panels::compare::show_survey(self, ui),
state::ViewMode::Reference => panels::compare::show_reference(self, ui),
state::ViewMode::People => panels::people::show(self, ui),
});
panels::second::show(self, &ctx);
panels::notices::show(self, &ctx);
@@ -789,6 +805,7 @@ mod drop_tests {
#[derive(Default)]
pub struct Caches {
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc<Vec<lightcraft_catalog::Person>>)>,
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
counts: Option<(u64, LibraryCounts)>,
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
@@ -835,6 +852,23 @@ impl Caches {
}
}
}
/// The people named on faces among the photos the filter lets through (its own `person` aside),
/// with photo counts.
pub fn people(
&mut self,
cat: &lightcraft_catalog::Catalog,
filter: &lightcraft_catalog::Filter,
) -> std::sync::Arc<Vec<lightcraft_catalog::Person>> {
let key = lightcraft_catalog::Filter { person: None, ..filter.clone() };
match &self.people {
Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(),
_ => {
let t = std::sync::Arc::new(cat.people_in(&key));
self.people = Some((cat.revision, key, t.clone()));
t
}
}
}
/// All Photos / Picks / Recently Deleted counts.
pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts {
if let Some((r, c)) = self.counts
+23 -6
View File
@@ -1,5 +1,5 @@
//! The menu bar model: File, Edit, View, Photo, Window, Help, generated from the command registry
//! (engine commands with menu paths + [`crate::menus::UI_COMMANDS`]) with live labels, shortcuts,
//! (engine commands with menu paths + [`crate::menus::ui_commands`]) with live labels, shortcuts,
//! enabled and checked state.
//!
//! One model drives every menu surface: the native macOS menu bar (built by the desktop host), the
@@ -119,9 +119,11 @@ const LAYOUT: &[(&str, &[&str])] = &[
"view.detail",
"view.compare",
"view.survey",
"view.people",
"---",
"view.leftPanel",
"view.photoCounts",
"view.faceBoxes",
"view.filmstrip",
"view.histogram",
"view.navigator",
@@ -186,12 +188,15 @@ const LAYOUT: &[(&str, &[&str])] = &[
"---",
"photo.saveMetadataToFile",
"photo.readMetadataFromFile",
"photo.reload",
"app.showInFinder",
"dialog.rename",
"dialog.captureTime",
"photo.tagFromTracklog",
"---",
"photo.delete",
"photo.restore",
"photo.deletePermanently",
],
),
(
@@ -252,11 +257,20 @@ const HIDDEN: &[&str] = &[
"preset.create",
];
/// Items only some hosts have are left out of the others' menus (the web build's library backup).
/// The selection includes a photo in Recently Deleted.
pub(crate) fn selection_deleted(app: &LightcraftApp) -> bool {
let s = &app.session;
s.selection.ids.iter().copied().chain(s.selection.active).any(|id| s.catalog.photo(id).is_some_and(|p| p.deleted))
}
/// Items only some hosts have are left out of the others' menus (the web build's library backup);
/// Restore and Delete Permanently replace Delete for photos in Recently Deleted.
fn host_supports(app: &LightcraftApp, id: &str) -> bool {
match id {
"file.backupLibrary" => app.services.backup_library.is_some(),
"file.restoreLibrary" => app.services.restore_library.is_some(),
"photo.restore" | "photo.deletePermanently" => selection_deleted(app),
"photo.delete" => !selection_deleted(app),
_ => true,
}
}
@@ -270,8 +284,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
let u = &app.ui;
let panel = |p: RightPanel| Some(u.right == p);
match id {
"app.language.english" => Some(u.language == crate::i18n::Language::En),
"app.language.japanese" => Some(u.language == crate::i18n::Language::Ja),
// Every language's command is checked when it is the active one.
_ if crate::menus::language_from_command(id).is_some() => Some(crate::menus::language_from_command(id) == Some(u.language)),
"develop.autoSync" => Some(app.session.auto_sync),
"view.photoCounts" => Some(u.show_counts),
"view.secondWindow" => Some(u.second_window),
@@ -280,8 +294,10 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
"view.detail" => Some(u.view == ViewMode::Detail),
"view.compare" => Some(u.view == ViewMode::Compare),
"view.survey" => Some(u.view == ViewMode::Survey),
"view.people" => Some(u.view == ViewMode::People),
"view.reference" => Some(u.view == ViewMode::Reference),
"view.leftPanel" => Some(u.left_panel),
"view.faceBoxes" => Some(u.face_boxes),
"view.filmstrip" => Some(u.filmstrip),
"view.histogram" => Some(u.histogram),
"view.clipping" => Some(u.show_clipping),
@@ -828,8 +844,9 @@ mod tests {
let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(titles, MENUS);
let all: Vec<MenuNode> = 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"] {
+43 -6
View File
@@ -10,9 +10,34 @@ use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom};
/// (id, label, shortcut, menu path)
pub type UiCommand = (&'static str, &'static str, Option<&'static str>, &'static str);
/// The "Edit → Language" entries, one per language in [`crate::i18n::Locale::ALL`], so a language
/// added to the table shows up in the menu (and in the control channel's command list) by itself.
pub const LANGUAGE_COMMANDS: &[UiCommand] = &[
("app.language.english", crate::i18n::Locale::En.name(), None, "Edit>Language"),
("app.language.simplifiedChinese", crate::i18n::Locale::ZhHans.name(), None, "Edit>Language"),
("app.language.traditionalChinese", crate::i18n::Locale::ZhHant.name(), None, "Edit>Language"),
("app.language.japanese", crate::i18n::Locale::Ja.name(), None, "Edit>Language"),
];
/// Every UI command: the languages, then everything else. `xtask parity` reads both tables from
/// this file, so an id listed in `docs/parity.md` is checked wherever it is declared.
pub fn ui_commands() -> impl Iterator<Item = &'static UiCommand> {
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<crate::i18n::Locale> {
match id {
"app.language.english" => Some(crate::i18n::Locale::En),
"app.language.simplifiedChinese" => Some(crate::i18n::Locale::ZhHans),
"app.language.traditionalChinese" => Some(crate::i18n::Locale::ZhHant),
"app.language.japanese" => Some(crate::i18n::Locale::Ja),
_ => None,
}
}
pub const UI_COMMANDS: &[UiCommand] = &[
("app.language.english", "English", None, "Edit>Language"),
("app.language.japanese", "日本語", None, "Edit>Language"),
("view.photoGrid", "Photo Grid", None, "View"),
("view.squareGrid", "Square Grid", None, "View"),
// G: Photo Grid ↔ Square Grid (from other views: the photo grid)
@@ -21,6 +46,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.detail", "Detail", Some("D"), "View"),
("view.compare", "Compare", Some("Shift+C"), "View"),
("view.survey", "Survey", Some("N"), "View"),
("view.people", "People", None, "View"),
("view.faceBoxes", "Face Boxes", None, "View"),
("view.reference", "Reference View", Some("Shift+R"), "View"),
("photo.setReference", "Set as Reference Photo", None, ""),
("compare.swap", "Swap Compare Photos", None, "View"),
@@ -206,8 +233,11 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if matches!(id, "app.language.english" | "app.language.japanese") {
app.ui.language = if id == "app.language.japanese" { crate::i18n::Language::Ja } else { crate::i18n::Language::En };
if let Some(language) = language_from_command(id) {
app.ui.language = language;
// Immediately, not on the next frame: the reply and anything else run this frame
// (menus rebuilt from it, toasts) are already in the new language.
crate::i18n::set_language(app.ui.language);
return Some(Ok(json!(app.ui.language)));
}
let ctx = egui::Context::default();
@@ -263,6 +293,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"view.compare" => crate::panels::compare::enter_compare(app),
"view.faceBoxes" => {
app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes);
Ok(json!({"show": app.ui.face_boxes}))
}
"view.people" => {
app.ui.view = ViewMode::People;
Ok(json!({"people": app.session.catalog.people().len()}))
}
"view.survey" => {
app.ui.view = ViewMode::Survey;
Ok(json!({"photos": crate::panels::compare::survey_photos(app).len()}))
@@ -1251,8 +1289,7 @@ pub struct MenuEntry {
/// The flattened menu model (UI commands + engine commands with menu paths).
pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> {
let mut v: Vec<MenuEntry> = UI_COMMANDS
.iter()
let mut v: Vec<MenuEntry> = ui_commands()
.filter(|c| !c.3.is_empty())
.map(|(id, label, sc, m)| MenuEntry {
id: id.to_string(),
+6
View File
@@ -26,6 +26,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
("detail", Icon::Single, ViewMode::Detail, "Detail (D)"),
("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"),
("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"),
("people", Icon::Subject, ViewMode::People, "People"),
] {
if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() {
let _ = app.run(&format!("view.{id}"), json!({}));
@@ -165,6 +166,11 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 {
{
let _ = app.run("view.beforeAfter", json!({}));
}
if app.ui.view == ViewMode::Detail
&& icon_button(&mut child, "faceBoxes", Icon::FaceBox, vec2(30.0, 30.0), app.ui.face_boxes, true, "Face boxes").clicked()
{
let _ = app.run("view.faceBoxes", json!({}));
}
if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() {
let _ = app.run("view.filmstrip", json!({}));
}
+245 -8
View File
@@ -51,6 +51,7 @@ pub enum Gesture {
mask: u32,
comp: usize,
handle: u8,
original_shape: MaskShape,
},
/// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates).
Guide {
@@ -333,6 +334,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let map = CanvasMap::new(&frame, img_rect);
let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag());
info_overlay(app, &p, canvas, &photo);
if !fullscreen {
filter_pill(app, ui, canvas);
}
if app.ui.face_boxes {
match region_overlay(ui, &p, &map, &photo, d.orientation) {
Some(RegionEdit::Remove(index)) => {
let _ = app.run("photo.removeRegion", json!({"id": id.0, "index": index}));
}
Some(RegionEdit::Resize(index, r)) => {
let _ = app.run("photo.setRegion", json!({"id": id.0, "index": index, "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}}));
}
None => {}
}
}
// a fine grid while a transform (geometry) slider is dragged, to judge verticals
if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) {
let n = 12;
@@ -420,6 +435,163 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li
register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y)));
}
/// What the user did to a face box this frame.
enum RegionEdit {
/// The × was clicked.
Remove(usize),
/// A handle drag ended: the region's new box (normalized, upright frame).
Resize(usize, lightcraft_geom::Rect),
}
/// Handles of a box: (x, y) as fractions of its width and height, and the cursor they show.
const REGION_HANDLES: [(f32, f32, egui::CursorIcon); 8] = [
(0.0, 0.0, egui::CursorIcon::ResizeNwSe),
(0.5, 0.0, egui::CursorIcon::ResizeVertical),
(1.0, 0.0, egui::CursorIcon::ResizeNeSw),
(1.0, 0.5, egui::CursorIcon::ResizeHorizontal),
(1.0, 1.0, egui::CursorIcon::ResizeNwSe),
(0.5, 1.0, egui::CursorIcon::ResizeVertical),
(0.0, 1.0, egui::CursorIcon::ResizeNeSw),
(0.0, 0.5, egui::CursorIcon::ResizeHorizontal),
];
/// Face/pet/focus regions read from XMP (MWG-RS), drawn as boxes over the photo. Hovering a box shows
/// a × in its corner and eight resize handles. A drag previews the new box live and is reported once,
/// on release (one undo step); the × reports the region to remove. Both are catalog-only edits:
/// LightCraft doesn't write regions to XMP. Regions are stored on the upright (EXIF-oriented) photo;
/// `orient` is the user's Rotate / Flip on top of it, which the loupe's normalized frame includes.
fn region_overlay(
ui: &egui::Ui,
p: &egui::Painter,
map: &CanvasMap,
photo: &lightcraft_catalog::Photo,
orient: lightcraft_geom::Orientation,
) -> Option<RegionEdit> {
let t = Tokens::get(p.ctx());
let clip = p.clip_rect();
let pointer = ui.input(|i| i.pointer.hover_pos());
// the box being dragged: (region index, its box so far)
let drag_key = egui::Id::new("region-drag");
let live: Option<(usize, lightcraft_geom::Rect)> = ui.data(|d| d.get_temp(drag_key));
let mut edit = None;
for (index, r) in photo.meta.regions.iter().enumerate() {
let dragging = live.filter(|(i, _)| *i == index);
let norm = orient.map_norm_rect(dragging.map_or(r.rect, |(_, n)| n));
let rect = Rect::from_two_pos(map.screen(Point::new(norm.x0, norm.y0)), map.screen(Point::new(norm.x1, norm.y1)));
// white with a black keyline just outside it, so the box shows on any background
p.rect_stroke(rect.expand(1.0), 0.0, Stroke::new(1.0, Color32::from_black_alpha(190)), StrokeKind::Outside);
p.rect_stroke(rect, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Outside);
if dragging.is_some() || (live.is_none() && pointer.is_some_and(|h| rect.expand(8.0).contains(h))) {
for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() {
let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height());
let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::new(("region-handle", index, h)), Sense::drag());
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(*cursor);
}
let sq = Rect::from_center_size(c, vec2(7.0, 7.0));
p.rect_filled(sq, 0.0, if resp.hovered() || resp.dragged() { Color32::from_rgb(120, 190, 255) } else { Color32::WHITE });
p.rect_stroke(sq, 0.0, Stroke::new(1.0, Color32::from_black_alpha(220)), StrokeKind::Outside);
if resp.dragged()
&& let Some(pp) = resp.interact_pointer_pos()
{
// move the dragged edge(s) to the pointer, never closer than 12 px to the opposite one
let mut n = rect;
if *fx == 0.0 {
n.min.x = pp.x.min(n.max.x - 12.0);
} else if *fx == 1.0 {
n.max.x = pp.x.max(n.min.x + 12.0);
}
if *fy == 0.0 {
n.min.y = pp.y.min(n.max.y - 12.0);
} else if *fy == 1.0 {
n.max.y = pp.y.max(n.min.y + 12.0);
}
let (a, b) = (map.norm(n.min), map.norm(n.max));
let shown = lightcraft_geom::Rect {
x0: a.x.min(b.x).clamp(0.0, 1.0),
y0: a.y.min(b.y).clamp(0.0, 1.0),
x1: a.x.max(b.x).clamp(0.0, 1.0),
y1: a.y.max(b.y).clamp(0.0, 1.0),
};
// back to the upright frame the region is stored in
let new = orient.inverse().map_norm_rect(shown);
ui.data_mut(|d| d.insert_temp(drag_key, (index, new)));
ui.ctx().request_repaint();
}
if resp.drag_stopped() {
if let Some((i, new)) = ui.data(|d| d.get_temp::<(usize, lightcraft_geom::Rect)>(drag_key)) {
edit = Some(RegionEdit::Resize(i, new));
}
ui.data_mut(|d| d.remove_temp::<(usize, lightcraft_geom::Rect)>(drag_key));
}
}
}
if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) {
let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0));
let resp = ui.interact(xr, egui::Id::new(("region-x", index)), Sense::click());
register(ui.ctx(), format!("regionRemove:{index}"), xr);
let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 });
p.rect_filled(xr, 2.0, fill);
p.rect_stroke(xr, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside);
let (c, m) = (xr.center(), 3.5);
let cross = Stroke::new(1.4, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(210) });
p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross);
p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross);
if resp.on_hover_text("Remove this face box (undo with Edit ▸ Undo)").clicked() {
edit = Some(RegionEdit::Remove(index));
}
}
let Some(name) = &r.name else { continue };
// the name in a dark label with a caret, centred above the box (below it when there is no room)
let g = p.layout_no_wrap(name.clone(), t.font(13.0), Color32::from_gray(225));
let (pad, caret) = (vec2(14.0, 7.0), 5.0);
let size = g.size() + pad * 2.0;
let above = rect.top() - caret - size.y >= clip.top();
let (top, tip, base) = if above {
(rect.top() - caret - size.y, rect.top() - 1.0, rect.top() - caret - 1.0)
} else {
(rect.bottom() + caret, rect.bottom() + 1.0, rect.bottom() + caret + 1.0)
};
let left = (rect.center().x - size.x / 2.0).clamp(clip.left(), (clip.right() - size.x).max(clip.left()));
let label = Rect::from_min_size(pos2(left, top), size);
let fill = Color32::from_rgba_unmultiplied(56, 56, 56, 235);
p.rect_filled(label, 3.0, fill);
p.rect_stroke(label, 3.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Inside);
let cx = rect.center().x.clamp(label.left() + caret + 4.0, label.right() - caret - 4.0);
p.add(egui::Shape::convex_polygon(vec![pos2(cx - caret, base), pos2(cx + caret, base), pos2(cx, tip)], fill, Stroke::NONE));
p.galley(label.min + pad, g, Color32::from_gray(225));
}
edit
}
/// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous
/// follow them, so a filter must never be invisible here. A click clears them all. It sits in the
/// canvas margin, so the photo does not move.
fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) {
let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);
if chips.is_empty() {
return;
}
let t = Tokens::get(ui.ctx());
let p = ui.painter_at(canvas);
let label = chips.iter().map(|c| c.label.as_str()).collect::<Vec<_>>().join(" · ");
let g = p.layout_no_wrap(format!("Filtered: {label}"), t.font(12.0), Color32::from_gray(225));
let w = (g.size().x + 40.0).min((canvas.width() - 24.0).max(60.0));
let r = Rect::from_min_size(canvas.min + vec2(12.0, 3.0), vec2(w, 20.0));
let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click());
register(ui.ctx(), "loupe:filters", r);
p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 }));
let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y));
p.with_clip_rect(text_clip).galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225));
let (c, m) = (pos2(r.right() - 13.0, r.center().y), 3.0);
let cross = Stroke::new(1.3, if resp.hovered() { Color32::WHITE } else { Color32::from_gray(190) });
p.line_segment([c - vec2(m, m), c + vec2(m, m)], cross);
p.line_segment([c - vec2(m, -m), c + vec2(m, -m)], cross);
if resp.on_hover_text("Click to clear these filters").clicked() {
let _ = app.run("library.clearFilter", json!({}));
}
}
/// A raw shown from its embedded JPEG (issue #10): a pill at the canvas' top centre saying so;
/// hovering it explains (registered as `notice:previewOnly:loupe`).
fn preview_only_pill(ui: &mut egui::Ui, canvas: Rect, reason: &str) {
@@ -867,10 +1039,49 @@ pub(crate) fn component_pin(shape: &MaskShape) -> Option<Point> {
}
/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`.
fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point) -> MaskShape {
fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape {
let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y);
match shape.clone() {
MaskShape::Radial { center, rx, ry, angle, feather, invert } => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert },
MaskShape::Radial { center, rx, ry, angle, feather, invert } => match handle {
1 | 3 => {
let l = frame_long_norm(map);
let dx = (at.x - center.x) / l.0;
let dy = (at.y - center.y) / l.1;
let (s, co) = angle.to_radians().sin_cos();
let new_rx = (dx * co + dy * s).abs().max(0.01);
let mut new_ry = ry;
if alt {
new_ry = ry * (new_rx / rx.max(0.01));
}
if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 {
new_ry = new_rx;
}
MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert }
}
2 | 4 => {
let l = frame_long_norm(map);
let dx = (at.x - center.x) / l.0;
let dy = (at.y - center.y) / l.1;
let (s, co) = angle.to_radians().sin_cos();
let new_ry = (dx * (-s) + dy * co).abs().max(0.01);
let mut new_rx = rx;
if alt {
new_rx = rx * (new_ry / ry.max(0.01));
}
if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 {
new_rx = new_ry;
}
MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert }
}
5 => {
let l = frame_long_norm(map);
let dx = (at.x - center.x) / l.0;
let dy = (at.y - center.y) / l.1;
let new_angle = dy.atan2(dx).to_degrees();
MaskShape::Radial { center, rx, ry, angle: new_angle, feather, invert }
}
_ => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert },
},
MaskShape::Linear { start, end } => match handle {
1 => MaskShape::Linear { start: at, end },
2 => MaskShape::Linear { start, end: at },
@@ -915,6 +1126,26 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
})
.collect();
p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230))));
let l = frame_long_norm(map);
for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() {
let a_local = a_deg.to_radians();
let (s, co) = angle.to_radians().sin_cos();
let (x, y) = (rx * a_local.cos(), ry * a_local.sin());
let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1));
p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE));
register(ui.ctx(), format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0)));
grips.push((m.id, ci, i as u8 + 1, q));
}
let (s, co) = angle.to_radians().sin_cos();
let rot_r = rx + 0.08;
let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1));
let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1));
p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200)));
p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE));
register(ui.ctx(), format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0)));
grips.push((m.id, ci, 5, rot_q));
}
MaskShape::Linear { start, end } if sel => {
let (a, b) = (map.screen(*start), map.screen(*end));
@@ -1003,21 +1234,25 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
let m = d.masks.iter().find(|m| Some(m.id) == active)?;
matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0))
});
if let Some((mask, comp, handle)) = grip {
if let Some((mask, comp, handle)) = grip
&& let Some(original_shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone())
{
if Some(mask) != active {
let _ = app.run("mask.select", json!({"id": mask}));
}
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"}));
app.gesture = Some(Gesture::MaskHandle { mask, comp, handle });
app.gesture = Some(Gesture::MaskHandle { mask, comp, handle, original_shape });
}
}
if resp.dragged()
&& let (Some(Gesture::MaskHandle { mask, comp, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
&& let Some(shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| &c.shape)
&& let (Some(Gesture::MaskHandle { mask, comp, handle, original_shape }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
{
let n = map.norm(q);
let n0 = map.norm(q - resp.drag_delta());
let new_shape = moved_shape(shape, handle, Point::new(n.x - n0.x, n.y - n0.y), n);
let press_origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(q);
let n0 = map.norm(press_origin);
let dn = Point::new(n.x - n0.x, n.y - n0.y);
let alt = ui.input(|i| i.modifiers.alt);
let new_shape = moved_shape(&original_shape, handle, dn, n, map, alt);
let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape}));
}
if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) {
@@ -1300,6 +1535,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
};
let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));
}
// the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted)
resp.context_menu(|ui| super::grid::context_menu(app, ui, *id));
}
});
}
+7 -8
View File
@@ -466,7 +466,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.separator();
}
builtin = b;
if ui.selectable_label(false, &p.name).clicked() {
if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() {
chosen = Some(p.name);
}
}
@@ -701,7 +701,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Import { opts } => crate::import::body(app, ui, opts),
Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab),
Dialog::ConfirmDelete { count } => {
let what = if *count == 1 { "this photo".to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) };
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
}
@@ -710,7 +710,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.add_space(10.0);
let discord = egui::Button::new(egui::RichText::new("Join the ArtCraft Discord").font(t.semibold(15.0)).color(egui::Color32::WHITE))
let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)
.min_size(egui::vec2(260.0, 34.0));
let r = ui.add(discord).on_hover_text(crate::links::DISCORD);
@@ -724,7 +724,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
("Source code on GitHub", crate::links::GITHUB),
("ArtCraft — more creative apps", crate::links::WEBSITE),
] {
let r = ui.link(label).on_hover_text(url);
let r = ui.link(crate::i18n::tr(label)).on_hover_text(url);
if r.clicked() {
let _ = crate::links::open(app, url);
}
@@ -733,7 +733,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Shortcuts => {
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
for (id, label, sc, _) in crate::menus::UI_COMMANDS {
for (id, label, sc, _) in crate::menus::ui_commands() {
if let Some(sc) = sc {
ui.label(crate::i18n::tr(label));
ui.label(*sc);
@@ -750,8 +750,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
for (sc, id, _) in crate::shortcuts::ALIASES {
let label = crate::menus::UI_COMMANDS
.iter()
let label = crate::menus::ui_commands()
.find(|c| c.0 == *id)
.map(|c| c.1)
.or_else(|| lightcraft_engine::find_command(id).map(|c| c.label))
@@ -776,7 +775,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
Dialog::Import { opts } => {
let n = opts.selected_paths().len();
let verb = if opts.copy && opts.move_files { "Move" } else { "Import" };
add_label = format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" });
add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n);
add_label.as_str()
}
Dialog::Merge { .. } => "Merge",
+11 -10
View File
@@ -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}));
+20 -6
View File
@@ -150,7 +150,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
folder_header(app, ui, hr, &b, &ids, local, &cnt)
}
None => {
let title = app.session.source.label(&app.session.catalog);
let title = crate::i18n::source_label(app.session.source, &app.session.catalog);
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
}
@@ -164,7 +164,11 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if !chips.is_empty() {
let body = match total {
Some(n) if n > 0 => {
crate::i18n::tr_format!("{n} photos in {} are hidden by the filters above", app.session.source.label(&app.session.catalog), n = n)
crate::i18n::tr_format!(
"{n} photos in {} are hidden by the filters above",
crate::i18n::source_label(app.session.source, &app.session.catalog),
n = n
)
}
_ => "Remove a filter above, or choose Clear all".to_string(),
};
@@ -409,7 +413,7 @@ fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids:
p.rect_filled(r, 0.0, t.canvas);
p.hline(r.x_range(), r.bottom(), Stroke::new(1.0, t.divider));
}
let g = p.layout_no_wrap(run.label.clone(), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label });
let g = p.layout_no_wrap(crate::i18n::date_group_label(&run.key, false), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label });
let x = r.left() + 8.0;
let gw = g.size().x;
p.galley(pos2(x, r.center().y - g.size().y / 2.0), g, t.text);
@@ -520,7 +524,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
.collect();
if parts.is_empty() { "—".to_string() } else { parts.join(" · ") }
}
"date" => photo.captured.as_deref().map(lightcraft_catalog::dates::display_time).unwrap_or_else(|| "No date".into()),
"date" => photo.captured.as_deref().map(crate::i18n::display_time).unwrap_or_else(|| crate::i18n::tr("No date").into()),
_ => photo.file_name.rsplit_once('.').map(|(n, _)| n.to_string()).unwrap_or(photo.file_name.clone()),
};
p.text(pos2(r.left() + 8.0, r.top() + 12.0), Align2::LEFT_CENTER, name, t.font(10.5), t.text_dim);
@@ -786,7 +790,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
ui.separator();
ui.menu_button(crate::i18n::tr("Set Rating"), |ui| {
for r in 0..=5 {
if ui.button(if r == 0 { "No Stars".to_string() } else { "★".repeat(r) }).clicked() {
if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() {
let _ = app.run("photo.rate", json!({"rating": r}));
}
}
@@ -821,6 +825,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if ui.button(crate::i18n::tr("Rename…")).clicked() {
let _ = app.run("dialog.rename", json!({}));
}
if ui.button(crate::i18n::tr("Reload from Disk")).clicked() {
let _ = app.run("photo.reload", json!({}));
}
if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() {
let _ = app.run("photo.virtualCopy", json!({}));
}
@@ -905,7 +912,14 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
});
ui.separator();
if ui.button(crate::i18n::tr("Delete Photo")).clicked() {
if crate::menubar::selection_deleted(app) {
if ui.button(crate::i18n::tr("Restore")).clicked() {
let _ = app.run("photo.restore", json!({}));
}
if ui.button(crate::i18n::tr("Delete Permanently")).clicked() {
let _ = app.run("photo.deletePermanently", json!({}));
}
} else if ui.button(crate::i18n::tr("Delete Photo")).clicked() {
let _ = app.run("photo.delete", json!({}));
}
}
+3 -5
View File
@@ -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);
}
}
+1
View File
@@ -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;
+87
View File
@@ -0,0 +1,87 @@
//! People: a card per person named on faces (read from XMP): a close-up of their largest face, the
//! name and how many photos they are in. A click shows that person's photos in the grid.
//!
//! Only the rows on screen ask for a face render (the engine caches them, memory and disk).
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use lightcraft_catalog::Person;
use serde_json::json;
use crate::LightcraftApp;
use crate::theme::Tokens;
use crate::widgets::register;
/// A card's face picture (points); the name and count sit below it.
const CARD: f32 = 150.0;
const LABEL_H: f32 = 44.0;
const GAP: f32 = 16.0;
const PAD: f32 = 20.0;
const HEADER_H: f32 = 44.0;
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let people = app.caches.people(&app.session.catalog, &app.session.filter);
let (head, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER_H), Sense::hover());
ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, "Named People", t.semibold(15.0), t.text);
ui.painter().text(pos2(head.right() - PAD, head.center().y), Align2::RIGHT_CENTER, people.len().to_string(), t.font(13.0), t.text_dim);
// the filters narrowing the list (a date, a keyword…), removable here
let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);
super::chips::show(app, ui, &chips);
if people.is_empty() {
let (title, body) = if chips.is_empty() {
("No named people yet", "Face names written to XMP by Lightroom and other apps show up here.")
} else {
("No named people in these photos", "Remove a filter above, or choose Clear all")
};
super::empty_message(ui, ui.available_rect_before_wrap(), title, body);
return;
}
let ppp = ui.ctx().pixels_per_point();
let active = app.session.filter.person.clone();
egui::ScrollArea::vertical().auto_shrink(false).show_viewport(ui, |ui, viewport| {
let width = ui.available_width();
let cols = (((width - PAD * 2.0 + GAP) / (CARD + GAP)).floor() as usize).max(1);
let row_h = CARD + LABEL_H + GAP;
let rows = people.len().div_ceil(cols);
let (area, _) = ui.allocate_exact_size(vec2(width, PAD * 2.0 + rows as f32 * row_h), Sense::hover());
let first = ((viewport.top() - PAD) / row_h).floor().max(0.0) as usize;
let last = (((viewport.bottom() - PAD) / row_h).ceil().max(0.0) as usize).min(rows);
for row in first..last {
for col in 0..cols {
let Some(person) = people.get(row * cols + col) else { break };
let min = area.min + vec2(PAD + col as f32 * (CARD + GAP), PAD + row as f32 * row_h);
let selected = active.as_deref().is_some_and(|a| a.eq_ignore_ascii_case(&person.name));
card(app, ui, person, Rect::from_min_size(min, vec2(CARD, CARD + LABEL_H)), ppp, selected);
}
}
});
}
fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ppp: f32, selected: bool) {
let t = Tokens::get(ui.ctx());
let face = Rect::from_min_size(r.min, vec2(CARD, CARD));
let resp = ui.interact(r, egui::Id::new(("person-card", &person.name)), Sense::click());
register(ui.ctx(), format!("person:{}", person.name), r);
let p = ui.painter();
p.rect_filled(face, 3.0, t.canvas);
if let Some(job) = app.session.face_job(person.photo, person.face, (CARD * ppp).ceil() as usize)
&& let Some(tex) = app.renderer.variant(job)
{
p.image(tex.tex.id(), face, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
if selected {
p.rect_stroke(face, 3.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
} else if resp.hovered() {
p.rect_stroke(face, 3.0, Stroke::new(1.0, t.text_dim), StrokeKind::Outside);
}
// a long name must not run past the card
let name =
if person.name.chars().count() > 19 { format!("{}…", person.name.chars().take(18).collect::<String>()) } 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!({}));
}
}
+13 -4
View File
@@ -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),
}
}
+6 -6
View File
@@ -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}));
+19 -8
View File
@@ -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}));
}
+2 -1
View File
@@ -91,7 +91,8 @@ fn choices<V: PartialEq + Copy>(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"));
+4 -4
View File
@@ -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()));
}
+6 -1
View File
@@ -16,6 +16,8 @@ pub enum ViewMode {
Survey,
/// A reference photo (left, fixed) beside the active photo (right, being edited).
Reference,
/// A card per person named on faces (close-up, name, photo count).
People,
}
/// The right-hand tool/panel shown next to the tool strip.
@@ -201,7 +203,7 @@ pub const MIN_PHOTO_WIDTH: f32 = 360.0;
#[serde(default, rename_all = "camelCase")]
pub struct UiState {
#[serde(default = "crate::i18n::default_language")]
pub language: crate::i18n::Language,
pub language: crate::i18n::Locale,
/// The Build Previews run last announced (its identity, finished?).
#[serde(skip)]
pub preview_build_seen: Option<(usize, bool)>,
@@ -262,6 +264,8 @@ pub struct UiState {
pub grid_info: String,
/// Photo counts next to sources and albums in the left panel.
pub show_counts: bool,
/// Face / pet boxes (read from XMP) over the photo in the loupe.
pub face_boxes: bool,
/// Local sidebar locations hidden with “Remove from Local” (folders on disk are untouched).
pub hidden_locations: Vec<String>,
/// Copies opened in an external editor this session (reloaded when the window is focused
@@ -548,6 +552,7 @@ impl Default for UiState {
show_filenames: true,
grid_info: "filename".into(),
show_counts: true,
face_boxes: true,
hidden_locations: Vec::new(),
dragging_control: None,
external_edits: Vec::new(),
+16 -12
View File
@@ -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::<Vec<_>>()
lightcraft_engine::fonts::cjk_fallback(craft, crate::i18n::language().script(), style)
.into_iter()
.map(craft_font_name)
.collect::<Vec<String>>()
};
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<String> = 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
+9 -3
View File
@@ -1,6 +1,6 @@
# Sony ARW starting look
# Sony ARW and Nikon NEF starting look
ARW decoding supplies a Bayer mosaic and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The ARW loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW mosaic; there is no JPEG replacement or uniform saturation boost.
ARW and NEF decoding supply a Bayer mosaic and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW mosaic; there is no JPEG replacement or uniform saturation boost.
## Colour and tone are separate
@@ -32,9 +32,15 @@ Five local Sony ILCE-6700 ARWs were accepted. Held-out per-channel linear-displa
Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, -7M4 ×2, -7RM4, -7SM3, DSC-RX100M3), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 7 fits accepted, ΔE 17–26 → 3–10 with matching mean lightness. (The RX100M3 was rejected at the time because the decoder read neither its white balance nor its black level and its render was green; since issue #148 its fit is accepted, held-out RMS 0.153 → 0.087.) Rejected and unchanged: an ILCE-7RM4 dusk scene whose fit was worse than the fallback (ΔE 7.6), and a reduced-size lossless ARW (no fit attempted). Saturated hues can still differ (an orange-red hull renders yellow). With the hue/saturation table, 28 local ILCE-7M4 ARWs (one month of photos, one in 24 picked) went from mean ΔE 4.74 to 4.27 against their camera JPEGs (200 px, CIE76); 27 accepted a table, none got worse overall by more than 0.1, and a few saturated regions moved away (12.4 → 15.2 on one). The lime shirt's hue error dropped from 16° to 6–8°. With the value axis, the chroma curve and gamma-domain selection, the same set plus the shirt photo (29 files) went from mean ΔE 4.23 to 3.54 (4.74 before the table), every file closer than before, saturated areas 7.65 → 6.41; all 29 now use a table. A small object stays hard: in a second shot of the lime shirt only 7 of the 6144 proxy pixels show it, so the table can't learn its colour and the shirt still renders yellower than the JPEG.
### Nikon NEF
The same fit, gates and relative WB apply to NEF/NRW (issue #150: NEFs rendered muted and greenish). Public CC0 samples from raw.pixls.us (D750 12/14-bit, D780 12-bit, D850 12/14-bit, D7500 12/14-bit lossless and lossy, Z 50 12-bit) plus five local D7500 shots of an indoor event, rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): all 13 decodable files accepted, held-out RMS 0.11–0.24 → 0.01–0.09, ΔE 13–46 → 2.9–7.8 with matching mean lightness. The exception is a mixed-light D7500 scene (faces lit by a purple screen): accepted (RMS 0.156 → 0.057) but only ΔE 12.7 → 12.0, rendered warmer and lighter than the camera JPEG; the linear-display error the gate measures is dominated by the bright screen. Files that still open as the embedded preview (lossy-after-split, uncompressed data labelled compressed) are not fitted.
Measuring this found a decoder bug: 12-bit NEFs store maker note `0x003d` BlackLevel in 14-bit units (D750 600, D780/Z 50 1008, D850/D7500 400, while their darkest samples are 150 / ~252 / 99), so 12-bit files rendered nearly black or with crushed shadows before any fit. The decoder now scales it to the sample depth; the fits for the same D7500 scene at 12 and 14 bits then agree (held-out RMS 0.042–0.049 vs 0.050–0.054; before, the 12-bit fits had to desaturate to compensate).
On one sample, Exposure +1 EV, Saturation +100, Contrast +50, warm/cool WB, tint, highlights and shadows all changed the output. The same matrix and curve were selected for 400-pixel and full-resolution renders. Headless UI checks wait until the loupe reports `source: render` and no jobs remain; the camera JPEG stand-in does not count as verification.
This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony and Nikon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
## Camera profiles (pooled per model)
+7 -3
View File
@@ -1,9 +1,9 @@
# LightCraft 日本語表示
LightCraft は日本語と英語(English)の表示に対応しています。
LightCraft は日本語・簡体字中国語・英語(English)の表示に対応しています。
言語設定は `ui.json` の `language` に保存します。
- 「編集 → 言語」または「設定 → 一般 → 言語」で日本語とEnglishを切り替えます。選択は次回起動にも引き継ぎます。
- 「編集 → 言語」または「設定 → 一般 → 言語」で切り替えます。選択は次回起動にも引き継ぎます。
- メニュー、写真編集、マスク、切り抜き、設定、読み込み、書き出し、主な処理状況を翻訳しています。
- 通常表示と太字の双方に、SIL OFLのBIZ UDPGothicを使います。日本語フォントはこのリポジトリではなく
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) にあり、任意のビルド入力
@@ -11,12 +11,16 @@ LightCraft は日本語と英語(English)の表示に対応しています
指定しない場合も動作しますが、日本語の文字は表示されません。
- 翻訳は表示層で行います。操作コマンドのID、写真のファイル名、入力したメタデータを変更しません。
- 翻訳されていない技術的なエラー、追加情報、リリースノートは英語で表示します。
- 中国語表示は [`localization-zh-hans.md`](localization-zh-hans.md) を参照してください。
- 別の言語の追加方法は [`localization.md`](localization.md) を参照してください。
## 翻訳の保守
静的な表示文言は `crates/ui-egui/locales/ja.json`、可変値を含む表示文言は
`crates/ui-egui/locales/ja-formats.json` にあります。英文をキーにして翻訳を管理します。
可変文言の両言語はビルド時にRustのフォーマット検査を受けます。
可変文言の訳文はすべてビルド時にRustの `format!` 検査を受けます。値は名前(`{n}`)または
位置(`{0}`、`{1}`)で参照し、`{:.1}` などの書式指定は英文と同じものを使います。
英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。
`LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。
+36
View File
@@ -0,0 +1,36 @@
# LightCraft 简体中文界面
LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 `ui.json` 的 `language` 字段中。
- 通过「编辑 → 语言」或「设置 → 一般 → 语言」切换,选择会保留到下次启动。
- 已翻译的范围:菜单、照片编辑、蒙版、裁剪、设置、导入、导出,以及主要的进度提示。
- 正文字重与半粗体都使用 SIL OFL 的 **Noto Sans CJK SC**(简体中文)与 BIZ UDPGothic(日语)。
这些字体不在本仓库,而在 [storytold/craft-fonts](https://github.com/storytold/craft-fonts);
使用可选的构建输入 `CRAFT_FONTS_DIR=../craft-fonts` 时(所有正式发布版)会内嵌进来。
不指定该输入时程序照常构建运行,但中文与日文文字没有字形,会显示为方框。
- 翻译只发生在显示层:命令 ID、照片文件名、用户输入的元数据都不做改动。
- 尚未翻译的技术性错误、补充说明和发行说明仍以英文显示。
- 已知限制:**网页版(wasm32)目前只内嵌日文字体**,因此浏览器里的中文仍显示为方框
(单个字体文件 16 MB,加上 wasm 会超过托管方 Cloudflare 的 25 MiB 单文件上限)。
原生桌面版不受影响。
- 日期分组标题与拍摄时间按中文格式显示(如 `2026年9月20日 星期日`)。
## 翻译的维护
静态界面文字在 `crates/ui-egui/locales/zh-hans.json`,含运行时数值的文字在
`crates/ui-egui/locales/zh-hans-formats.json`。两者都以英文原文为键。
含数值的文字遵循以下约定:
- 每个译文都是普通的 Rust 格式字符串,构建时由 `format!` 对照调用点的参数检查(占位符写错会编译失败)。
- **按名字**:`{n}`、`{label}`、`{total}` 等与英文原文中的名字一致。
- **按位置**:`{0}`、`{1}` … 对应调用点无名参数的先后顺序(从 0 开始),用于调整语序,
例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{1}”{0:.0}`。
- 保留英文原文的格式说明(`{:.1}`、`{d:+}`、`{:.0}%`)。
- `format!` 不允许未使用的参数:英文用来表示单复数的那个参数(`"s"` / `""`)在中文里写成
`{:.0}`(精度为 0 的字符串不输出任何内容)。
`LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。
翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由
`cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。
+46
View File
@@ -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
```
測試涵蓋翻譯目錄、指令/選單文字、語言切換、設定保存、資料名稱保持原樣及桌面字形。
+86
View File
@@ -0,0 +1,86 @@
# Localization
The interface ships in the language it is written in (English) plus every language in the table in
`crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional
Chinese (`zh-hant`, Taiwan) and Japanese (`ja`). This file is the reference for **adding or maintaining a language**; the per-language notes
are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md) and
[`localization-ja.md`](localization-ja.md).
## Adding a language
1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to
`locales/<code>.json` and `locales/<code>-formats.json`, then translate the values. Keys are the
English source strings and must stay byte-for-byte identical. The plain catalog may grow at its
own pace: a message it lacks shows in English, and `cargo test -p lightcraft-ui-egui i18n::tests
-- --nocapture` lists the gaps (it fails on an empty entry or mismatched placeholders). The
formats catalog must carry every message (the build fails otherwise), including the date
patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and
capture times use in every language but English; weekday names go in the plain catalog.
2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`:
```rust
language_table! {
En, "en";
ZhHans, "zh-hans", "简体中文", "Hans", include_str!("../locales/zh-hans.json");
ZhHant, "zh-hant", "繁體中文(台灣)", "Hant", include_str!("../locales/zh-hant.json");
Ja, "ja", "日本語", "Jpan", include_str!("../locales/ja.json");
}
```
The fields are the BCP-47 code (also the settings-file value), the **endonym** shown in the
Language menu, the ISO 15924 script (`Latn`, `Jpan`, `Hans`, `Hant`, `Kore`…), and the embedded
catalog. `build.rs` picks up `<code>-formats.json` by name, so the macro and the format lookup
follow automatically — nothing else in the UI needs to change. The menus, the settings row, the
keyboard-shortcut sheet and the control channel all read the table.
3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a
language whose script has no face in the build shows boxes (the test says so instead of failing).
Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a
missing face is added there, not worked around in this repository.
4. Run `cargo xtask ci` (it includes `fmt`, `clippy`, the tests, `parity` and the wasm check).
## How a message is found
`tr("Exposure")` looks the English label up in the active language's catalog and returns the source
text unchanged when there is no entry; `tr_format!` handles text with runtime values. Both are
presentation-only: command ids, file names and user-entered metadata are never translated.
`tr_format!` is generated by `crates/ui-egui/build.rs`: one arm per English message, which formats
the active language's translation with `format!` (English is the fallback arm). Every catalog's
format string is therefore checked by Rust against the call site's own arguments:
```rust
tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album, n = n)
```
- A translation is an ordinary Rust format string over the same arguments. Refer to named values by
name (`{n}`) and to positional ones by index when the word order changes (`{0}`, `{1}`):
`"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`).
- `format!` rejects an unused argument, so a value a language does not need — the English plural
suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing).
- Every formats catalog carries the same messages (`build.rs` fails the build otherwise), and a
`tr_format!` call whose message is in no catalog does not compile: add it to every
`*-formats.json` file.
## Testing and looking at it
```sh
cargo test -p lightcraft-ui-egui i18n::tests # catalogs, keys, placeholders, commands
CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage
```
The glyph tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then).
To look at a language, render it headless:
```sh
LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot --demo --script tour.jsonl -o out.png --size 1600x1000
```
or, with the app running (`--control 7980`), run the language command
(`engine.execute {"command": "app.language.simplifiedChinese"}`) and take a screenshot.
## Language codes and settings
The settings file stores the BCP-47 code, never the Rust variant name, so a language can be renamed
in code without invalidating anyone's saved preference. `LIGHTCRAFT_LANGUAGE` accepts what a system
locale looks like (`zh_Hans`, `zh-CN`, `en_US`, `ja_JP.UTF-8`) and falls back to the base language
when a region has no dedicated entry.
+17 -16
View File
@@ -22,9 +22,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done |
|---|---:|---:|---:|---:|---:|---:|
| A. Import (IMP) | 11 | 3 | 1 | 1 | 3/5 (60%) | 5/5 (100%) |
| B. Library management (LIB) | 22 | 1 | 2 | 2 | 9/9 (100%) | 9/9 (100%) |
| B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) |
| C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) |
| D. Search & filter (FILT) | 11 | 0 | 2 | 0 | 4/4 (100%) | 4/4 (100%) |
| D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) |
| E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) |
| F. Edit panel — global adjustments (EDIT) | 42 | 1 | 5 | 1 | 28/28 (100%) | 13/14 (93%) |
| G. Profiles (PROF) | 6 | 2 | 3 | 0 | 3/4 (75%) | 2/3 (67%) |
@@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 15 | 3 | 2 | 1 | 8/8 (100%) | 6/8 (75%) |
| X. Cross-cutting behaviours (BEHAV) | 16 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
| Lightroom Classic extras | 31 | 9 | 40 | 9 | — | 21/22 (95%) |
| **Total** | 387 | 29 | 91 | 37 | 194/200 (97%) | 139/149 (93%) |
| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) |
| **Total** | 388 | 33 | 87 | 37 | 194/200 (97%) | 139/149 (93%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.2%** of 507 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 39.2% of 158.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.6%** of 508 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 40.9% of 159.
<!-- /parity:summary -->
## Top gaps
@@ -59,7 +59,7 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu
that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)).
Take the first one nobody is working on.
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW now gets a guarded file-local fit to its own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW and Nikon NEF now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate`; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, RW2 v4, Nikon
"lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see
`crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`.
@@ -122,7 +122,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, RW2 v4, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode |
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
@@ -145,10 +145,10 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle |
| LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels |
| LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop |
| LR-LIB-PEOPLE | People / faces | P2 | ⬜ | | |
| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back |
| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack |
| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M |
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" |
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; no confirmation dialog, no auto-purge after N days, no "Empty" |
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |
| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) |
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow |
@@ -169,7 +169,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | |
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put |
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put |
| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar; a click on the photo (and Z / Space) eases to the click-zoom ratio (1:1 default, 2:1, 3:1, 4:1, 8:1 in the bottom bar) |
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session |
@@ -195,7 +195,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-FILT-KEYWORD | Keyword filter | P1 | ✅ | `cmd:library.filter` (`keyword`), `crates/ui-egui/src/panels/filterbar.rs` | keyword picker |
| LR-FILT-CAMERA | Camera / lens filter | P1 | ✅ | `cmd:library.filter` (`camera`, `lens`), `crates/ui-egui/src/panels/filterbar.rs` | camera and lens pickers |
| LR-FILT-LOCATION | Location filter | P2 | ✅ | `cmd:library.filter` (`text`, `ruleSet` field `location`) | free text, or a rule on location / city / state / country (smart albums, `library.filter`) |
| LR-FILT-PEOPLE | People filter | P2 | ⬜ | | |
| LR-FILT-PEOPLE | People filter | P2 | 🟡 | `cmd:library.filter`, `crates/catalog/src/query.rs` | filter by a person's name (case-insensitive, named Face regions only) from the People view, a filter chip or `person:`; no unnamed / suggested people |
| LR-FILT-CULL | Culling-score filters | P2 | ✅ | `cmd:library.filter` (`ruleSet` fields `sharpness`, `bestOfGroup`) | Focus and Best of Similar Shots in the rule editor / smart albums |
| LR-FILT-SORT | Sort | P0 | ✅ | `cmd:library.sort`, `crates/ui-egui/src/panels/bottombar.rs` | no colour-label or custom (manual) order |
| LR-FILT-SAVED | Filter presets [Classic] | P2 | ✅ | `cmd:filter.savePreset`, `cmd:filter.applyPreset`, `cmd:filter.presets`, `cmd:filter.deletePreset`, `crates/ui-egui/src/panels/filterbar.rs` | filter bar → Presets: apply, save current filter, delete (right-click); saved with the library |
@@ -274,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a local per-model profile pooled from many photos (`lightcraft-cli calibrate`, `crates/engine/src/camera_profiles.rs`), the profile's colour and only its own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW and Nikon NEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a local per-model profile pooled from many photos (`lightcraft-cli calibrate`, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
@@ -503,7 +503,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization-ja.md` | Japanese/English UI with persisted language preference and Japanese regular/bold fonts; menus, edit, crop/masks, settings, import/export and primary progress messages. Technical errors/release notes and other locales remain untranslated. |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Traditional Chinese catalog is the most complete, the others fall back to English for messages they lack. Technical errors/release notes remain English; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |
| LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | |
| LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights |
| LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | |
@@ -600,7 +601,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | |
| MENU-HELP-FEEDBACK | Send feedback | P2 | ✅ | `cmd:app.feedback` | opens a new issue on the project's GitHub |
| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) |
| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete |
| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) |
| MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu |
| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules |
| MENU-CTX-MASK | Mask / component menu | P0 | ✅ | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`, `component_row_menu`), `cmd:mask.component` | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) |
@@ -724,7 +725,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ✅ | `crates/catalog/src/keywords.rs`, `cmd:keyword.list`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops) |
| LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) |
| LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step |
| LRC-LIB-PEOPLE | People view | P2 | ⬜ | | |
| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `crates/ui-egui/src/panels/people.rs` | Named People cards (face close-up, name, photos) for the photos the active filters let through, with the filter chips shown above them; no Unnamed People, no per-person page, no confirm / reject |
| LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | |
| LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) |
| LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike |
+14
View File
@@ -83,6 +83,16 @@
changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.
### Library
- Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the
Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no
longer just says "duplicate skipped": it opens the side panel on Recently Deleted with the photo selected and says
how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,
Cmd+Shift+R, keeps the photo and clears its edits.)
- Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the
1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw
data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in
the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they
were missing, without touching anything already set.
- Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive
volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets
`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.
@@ -124,6 +134,10 @@
- Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.
- Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.
- ⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.
- Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,
greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs
(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was
read in the wrong units.
### Viewing and sharing
- Slideshow, second window, All Metadata, System Info.
+20
View File
@@ -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 `<file>.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
+8
View File
@@ -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",
+24 -5
View File
@@ -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<String> {
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<String>, id: &str) -> bool {
@@ -437,5 +442,19 @@ old
fn reads_ui_command_ids() {
let src = "pub const UI_COMMANDS: &[UiCommand] = &[\n (\"view.detail\", \"Detail\", Some(\"D\"), \"View\"),\n (\"app.about\", \"About\", None, \"\"),\n];\nfn x() { (\"not.this\", 1); }";
assert_eq!(ui_command_ids(src), ["view.detail", "app.about"]);
let languages =
"pub const LANGUAGE_COMMANDS: &[UiCommand] = &[\n (\"app.language.english\", Locale::En.name(), None, \"Edit>Language\"),\n];\n";
assert_eq!(ui_command_ids(&format!("{languages}{src}")), ["view.detail", "app.about", "app.language.english"]);
}
/// The real menu tables: both are found, so every language command is a known id.
#[test]
fn reads_the_language_commands_from_menus_rs() {
let menus = include_str!("../../crates/ui-egui/src/menus.rs");
let ids = ui_command_ids(menus);
for id in ["view.detail", "app.language.english", "app.language.japanese", "app.language.simplifiedChinese"] {
assert!(ids.iter().any(|known| known == id), "{id} not found");
}
assert_eq!(ids.iter().filter(|id| id.as_str() == "app.language.english").count(), 1, "one Language menu entry per language");
}
}