- Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.
+ Photo library and raw development; an open-source, non-destructive photo library and raw developer, built in pure Rust.
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
@@ -129,7 +129,7 @@ Split-tone shadows, midtones and highlights independently with drag-anywhere col
## Before & after
-Hold \\ to peek at the original, press Y for side by side, or Shift+Y for a
+Press \\ to toggle the Before view, Y for side by side, or Shift+Y for a
split view. Every image below is a real screenshot of LightCraft, captured automatically by an agent through the
[control channel](#built-for-agents).
@@ -150,9 +150,13 @@ split view. Every image below is a real screenshot of LightCraft, captured autom
Paint with a **Brush** (size, feather, flow, density, erase), drop **Linear** and **Radial Gradients** with draggable
pins, or select by **Luminance Range**, **Color Range**, **Sky**, **Subject** and **Background**. Combine components
-with **Add / Subtract / Intersect**, invert any of them, and dial in 15 local adjustments per mask (Temp, Tint,
-Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise)
-plus an overall Amount.
+with **Add / Subtract / Intersect**, invert any of them, and dial in 17 local adjustments per mask (Temp, Tint,
+Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise,
+Moiré, Defringe), plus an overall Amount.
+
+Desktop **Object** and **Describe** masks use SAM 3 for click-based and text-prompt selection, with a separately
+installed model; see [model setup and terms](docs/ai-masks.md). Subject, Sky and Background selections still use
+classical heuristics. Existing model-generated masks render and export without the model installed.
@@ -164,7 +168,7 @@ plus an overall Amount.
## Presets, profiles & the Color Mixer
-Eighteen hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp
+Forty-one hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp
Landscape* and more), each with an **Amount** slider from 0 to 200 %. Save your own from any group of settings, mark
favourites, and copy, paste or sync edits across a whole selection with exactly the groups you choose.
@@ -209,6 +213,10 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick
import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized,
so they stay smooth whether you have forty photos or forty thousand.
+The **People** view groups named faces, including names read from XMP; face boxes can be edited in the loupe.
+On desktop, automatic detection and recognition use optional models installed through **Settings → Faces**; no weights are
+bundled. See [face models and setup](docs/faces.md).
+
Photo Grid with albums nested in a folder, ratings and flags. Demo library.
@@ -281,7 +289,13 @@ lightcraft --control 7980 ~/Pictures/trip
print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
```
- Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet.
+ The Print Job panel supports printer/paper ICC profiles, rendering intent, black point compensation,
+ print adjustment, sharpening and 8/16-bit PDF output. Direct printing through an OS print dialog and page
+ overlays (identity plate, watermark, page numbers) are not there yet.
+- **Slideshows:** **View → Slideshow** plays the photos in view full screen, with fades, repeat and random order.
+ **Settings → Slideshow** configures layouts, templates, backgrounds, overlays and title screens. Native exports
+ support PDF, JPEG sequences and AV1 video in MP4 or WebM; music and a dedicated Slideshow module editor remain
+ missing. See [docs/slideshow.md](docs/slideshow.md).
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
side door.
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
@@ -293,7 +307,7 @@ lightcraft --control 7980 ~/Pictures/trip
- **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans,
Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP,
- PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
+ PSD composites, JPEG XL, GIF (first frame) and BMP open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice,
as in PhotoCraft).
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
@@ -319,17 +333,21 @@ lightcraft --control 7980 ~/Pictures/trip
LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and
[docs/target-app-parity.md](docs/target-app-parity.md)):
-- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
+- **By feature count we're at ~82%** (core features 98%), tracked row by row in
[docs/parity-checklist.md](docs/parity-checklist.md).
-- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
+- **As a day-to-day tool we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
- **The biggest gaps:**
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
- - **AI masks and denoise:** subject and sky selection are classical heuristics;
+ - **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an
+ optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and
+ linear-RGB denoise and Super Resolution remain missing; see [AI masks](docs/ai-masks.md) and [AI Denoise](docs/denoise.md);
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
- - **on-screen HDR display, video and the Classic Print / Book / Map modules.**
+ - **on-screen HDR display, video import/editing, and the Book / Map module views.** Print layouts and
+ colour-managed PDF output work; direct printer output and page overlays remain missing. Slideshow playback
+ and export work, with a dedicated module editor and music still missing.
- **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md).
| Area | Status |
@@ -337,16 +355,16 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| Library: albums, folders, smart albums, stacks (incl. auto-stack), virtual copies, ratings, flags, labels, filter bar, search, sort, grids, filmstrip | ✅ |
| Culling: Compare (synced zoom) and Survey views, auto-advance, instant previews | ✅ |
| Light, Color, Effects (vignette styles), Tone Curve (+ refine saturation, targeted adjustment), Color Mixer (+ targeted), Point Color, Color Grading, Calibration, B&W | ✅ |
-| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
+| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect; Object and Describe selections | ✅ · Object/Describe require optional SAM 3; Subject/Sky remain heuristic ([details](docs/ai-masks.md)) |
| Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ |
| Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ |
-| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing |
+| Camera colour: DNG matrices, guarded camera-JPEG look fits and built-in profiles for selected cameras | 🚧 measured calibration and per-model coverage remain incomplete ([raw parity](docs/raw-parity.md)) |
| Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ |
| RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) |
-| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), Super Resolution ⬜ |
-| Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 |
-| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
-| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ |
+| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights; [setup](docs/denoise.md)), Super Resolution ⬜ |
+| Remove / Heal / Clone spots (auto source), non-generative Fill, editable spot pins, Visualize Spots, Red Eye and Pet Eye | ✅ · Fill uses PatchMatch; no generative fill |
+| Export: JPEG / PNG / TIFF / flat PSD / WebP / AVIF / JPEG XL / raw or rendered DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ |
+| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; gain map JPEG, PQ/HLG AVIF, PQ PNG/JPEG XL and float TIFF export | ✅ · HDR display ⬜ |
| Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ |
| Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ |
| MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ |
@@ -354,7 +372,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
| Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ |
| Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ |
| GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 |
-| AI: segmentation masks, AI denoise, super resolution, faces; video | 🚧 (see [gaps](docs/gaps.md)) |
+| People view, XMP face names and editable face boxes; automatic face detection and recognition | ✅ · detection/recognition require optional models ([setup](docs/faces.md)) |
+| GPX tracklog auto-tagging and offline reverse geocoding | ✅ · place names require the GeoNames dataset; Map view ⬜ |
+| Print layouts, picture packages, templates and colour-managed PDF export | ✅ layouts/PDF · 🚧 direct printer output and page overlays |
+| Slideshow playback, layouts, overlays, templates and PDF/JPEG/AV1 export | ✅ playback · 🚧 dedicated module editor and music ([details](docs/slideshow.md)) |
+| Video import/editing and Book module | ⬜ |
| Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 |
✅ works today · 🚧 in progress · ⬜ not started
@@ -373,7 +395,7 @@ cargo run --release -p lightcraft -- --memory # a throwaway in-memory
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/
cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
-cargo xtask ci # fmt, clippy, tests, layering, wasm checks
+cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM
```
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
@@ -426,7 +448,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
-**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General →
+**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) or **Settings → General →
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
@@ -491,9 +513,9 @@ egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp`
Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. The short version:
-- **Clean-room.** Never read Adobe binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
+- **Clean-room.** Never read proprietary binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…);
work from public specs and black-box observation.
-- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every
+- **No proprietary assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from commercial products. Every
image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has
an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to
[storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here.
@@ -536,7 +558,7 @@ option to start it. For unattended installs: `msiexec /i lightcraft--window
| Format | x86_64 | aarch64 (ARM64) | Notes |
|---|---|---|---|
-| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
+| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Portable; updates with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
| Flatpak | `lightcraft--linux-x86_64.flatpak` | `lightcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` |
| Debian/Ubuntu | `lightcraft--linux-x86_64.deb` | `lightcraft--linux-aarch64.deb` | |
| Fedora/RHEL/openSUSE | `lightcraft--linux-x86_64.rpm` | `lightcraft--linux-aarch64.rpm` | |
@@ -623,8 +645,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.
-
Made by the ArtCraft team and community.
diff --git a/README.zh-hans.md b/README.zh-hans.md
index f2d1552147..58e1d627fa 100644
--- a/README.zh-hans.md
+++ b/README.zh-hans.md
@@ -18,7 +18,7 @@
- 照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。
+ 照片图库与 raw 显影;开源、非破坏性的照片图库与 raw 显影工具,用纯 Rust 构建。
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。
由 ArtCraft 团队和社区制作。
diff --git a/contributors/people.toml b/contributors/people.toml
index bec67597c2..08d002e8b4 100644
--- a/contributors/people.toml
+++ b/contributors/people.toml
@@ -45,3 +45,15 @@ sync_github_name = true
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+
+[people.nledford]
+real_name = "Nathaniel Ledford"
+
+[people.marcoarnulfo]
+real_name = "Marco Arnulfo"
+display_name = "Marco Arnulfo"
+sync_github_name = false
+
+[people.egegl]
+real_name = "Ege Gursel"
+sync_github_name = true
diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs
index d180d013dd..4d16c2f4dc 100644
--- a/crates/catalog/src/dates.rs
+++ b/crates/catalog/src/dates.rs
@@ -49,7 +49,9 @@ pub fn shift_iso(iso: &str, secs: i64) -> Option {
if !(first..=last).contains(&t) {
return None;
}
- let tail = iso.trim().get(19..).unwrap_or("");
+ let iso = iso.trim();
+ // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
+ let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or("");
Some(format!("{}{tail}", civil(t)))
}
@@ -222,6 +224,15 @@ mod tests {
assert_eq!(normalize_iso("2026-02-30"), None);
}
+ #[test]
+ fn shift_keeps_the_zone_of_a_time_without_seconds() {
+ assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00"));
+ assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z"));
+ assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00"));
+ assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00"));
+ assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
+ }
+
#[test]
fn runs_by_day_month_year_keep_stacks_together() {
let mut c = Catalog::new();
diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs
index 289ee4fb8d..edcfae22ae 100644
--- a/crates/catalog/src/journal.rs
+++ b/crates/catalog/src/journal.rs
@@ -271,7 +271,7 @@ fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> {
const START: &[u8] = b"{\"seq\":";
(1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| {
let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?;
- (rec.0 <= last + 1).then_some((i, rec))
+ (rec.0 <= last.saturating_add(1)).then_some((i, rec))
})
}
@@ -298,6 +298,9 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io
Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() })
}
+/// The highest `seq` a snapshot may hold; beyond it the counter is treated as corrupt, far from overflow.
+const MAX_SEQ: u64 = u64::MAX / 2;
+
/// Just the identification of `catalog.snap` (read before the catalog itself).
#[derive(serde::Deserialize)]
struct SnapHeader {
@@ -331,6 +334,10 @@ impl Journal {
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
}
let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
+ // a seq this high can't come from real use; the next record's seq would wrap
+ if s.seq > MAX_SEQ {
+ return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: seq {} is out of range", s.seq)));
+ }
let mut catalog = s.catalog;
report.repaired = catalog.repair();
for r in &report.repaired {
@@ -392,7 +399,7 @@ impl Journal {
let _ = op;
report.stale += 1;
}
- Some((seq, op)) if seq == j.seq + 1 => {
+ Some((seq, op)) if j.seq.checked_add(1) == Some(seq) => {
if catalog.apply(op).is_err() {
report.failed += 1;
} else {
@@ -476,7 +483,7 @@ impl Journal {
let mut buf = String::new();
let mut seq = self.seq;
for op in ops {
- seq += 1;
+ seq = seq.checked_add(1).ok_or_else(|| CatalogError::Corrupt(format!("{LOG}: op counter overflow")))?;
buf.push_str(&encode_record(seq, op));
buf.push('\n');
}
@@ -527,7 +534,7 @@ impl Journal {
log::warn!("catalog: background snapshot failed ({e}); writing one now");
}
let t0 = web_time::Instant::now();
- let seq = self.seq + unlogged;
+ let seq = self.seq.checked_add(unlogged).ok_or_else(|| CatalogError::Corrupt(format!("{SNAPSHOT}: op counter overflow")))?;
let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) {
Ok(t) => t,
Err(e) => {
diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs
index 0c7ca51123..6ac3544ee7 100644
--- a/crates/catalog/src/model.rs
+++ b/crates/catalog/src/model.rs
@@ -643,6 +643,7 @@ mod edited_tests {
("NIKON CORPORATION NIKON D850", "NEF", false),
("Canon Canon EOS 5D Mark III", "CR2", false),
("Canon Canon EOS R6m2", "CR3", false),
+ ("Canon Canon EOS R6 Mark II", "CR3", false),
("Canon Canon EOS R7", "CR3", true),
("FUJIFILM X-T4", "RAF", false),
("ILCE-7M3", "ARW", false),
diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs
index d1548f8bdf..7625c9ab64 100644
--- a/crates/catalog/src/query.rs
+++ b/crates/catalog/src/query.rs
@@ -157,13 +157,20 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
Some((_, v)) => (x - v).abs() < 1e-9,
None => false,
};
+ // Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8.
+ let cmp32 = |x: f32, s: &str| match num(s) {
+ Some(('>', v)) => x > v as f32,
+ Some(('<', v)) => x < v as f32,
+ Some((_, v)) => x == v as f32,
+ None => false,
+ };
return match field {
"rating" | "stars" => cmp(p.rating as f64, val),
"flag" => Flag::parse(val) == Some(p.flag),
"label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)),
"iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)),
- "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)),
- "focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)),
+ "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp32(a, val)),
+ "focal" => p.meta.focal_mm.is_some_and(|a| cmp32(a, val)),
"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)),
@@ -173,11 +180,18 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
"date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)),
"copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(),
"name" | "file" => p.file_name.to_lowercase().contains(val),
- _ => false,
+ // Not a known field: the colon is part of a plain word ("10:30", "Re:").
+ _ => text_matches(p, &t),
};
}
+ text_matches(p, &t)
+}
+
+/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
+/// format or a keyword of `p`.
+fn text_matches(p: &Photo, t: &str) -> bool {
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
- hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
+ 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).
diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs
index a713b77361..eb7e89ef4e 100644
--- a/crates/catalog/src/stacks.rs
+++ b/crates/catalog/src/stacks.rs
@@ -294,7 +294,8 @@ pub fn iso_seconds(s: &str) -> Option {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
- let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) };
+ let (hh, mm, ss) =
+ if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) };
// days from civil (proleptic Gregorian)
let y2 = if m <= 2 { y - 1 } else { y };
let era = y2.div_euclid(400);
diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs
index 8f1aaf1514..a32b5acf5a 100644
--- a/crates/catalog/src/tests.rs
+++ b/crates/catalog/src/tests.rs
@@ -123,6 +123,48 @@ fn filter_search_sort() {
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
}
+/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain
+/// text, not a fielded token that matches nothing.
+#[test]
+fn free_text_with_a_colon_searches_the_text_fields() {
+ let mut c = Catalog::new();
+ let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00");
+ let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00");
+ let mut m = c.photo(a).unwrap().meta.clone();
+ m.caption = "Meeting at 10:30 with Re: notes".into();
+ m.iso = Some(1600);
+ c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
+ let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
+ assert_eq!(q("meeting"), vec![a]);
+ assert_eq!(q("10:30"), vec![a], "a free-text token with a colon");
+ assert_eq!(q("re:"), vec![a]);
+ assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word");
+ assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded");
+ assert_eq!(q("iso:>2000"), Vec::::new());
+ assert_eq!(q("11:45"), Vec::::new(), "an unknown field matching no text finds nothing");
+}
+
+/// `f:`/`aperture:` and `focal:` are stored as `f32`; a typed value that isn't exact in binary
+/// (2.8, 5.6) must still match, and `<`/`>` must not count the photo's own value.
+#[test]
+fn search_tokens_match_f32_aperture_and_focal() {
+ let mut c = Catalog::new();
+ let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00");
+ let mut m = c.photo(a).unwrap().meta.clone();
+ m.aperture = Some(2.8);
+ m.focal_mm = Some(5.6);
+ c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
+ let q = |t: &str| c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default());
+ assert_eq!(q("f:2.8"), vec![a], "f:2.8");
+ assert_eq!(q("aperture:=2.8"), vec![a], "aperture:=2.8");
+ assert_eq!(q("focal:5.6"), vec![a], "focal:5.6");
+ assert!(q("f:2.9").is_empty(), "f:2.9");
+ assert!(q("f:<2.8").is_empty(), "f:<2.8");
+ assert!(q("f:>2.8").is_empty(), "f:>2.8");
+ assert!(q("focal:<5.6").is_empty(), "focal:<5.6");
+ assert_eq!(q("f:>2.7"), vec![a], "f:>2.7");
+}
+
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs
index 8570bd077e..26b4e902ab 100644
--- a/crates/catalog/src/tests_journal.rs
+++ b/crates/catalog/src/tests_journal.rs
@@ -376,3 +376,18 @@ fn a_damaged_process_value_loads_as_v1_with_the_edits() {
let d = &loaded.photo(id).unwrap().develop;
assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8));
}
+
+/// A snapshot whose `seq` is near `u64::MAX` is refused as corrupt: the next record's seq would
+/// wrap (and every later edit would be skipped as stale on reopen) or overflow-panic in debug.
+#[test]
+fn snapshot_with_seq_near_u64_max_is_corrupt() {
+ for seq in [u64::MAX, u64::MAX - 1, u64::MAX / 2 + 1] {
+ let m = MemStore::new();
+ m.set(
+ SNAPSHOT,
+ format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{seq},\"catalog\":{}}}\n", Catalog::new().to_snapshot()).into_bytes(),
+ );
+ let e = Journal::open(Box::new(m)).err();
+ assert!(matches!(e, Some(CatalogError::Corrupt(_))), "seq {seq}: {e:?}");
+ }
+}
diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml
index 1ae016bc88..589d285fae 100644
--- a/crates/codecs/Cargo.toml
+++ b/crates/codecs/Cargo.toml
@@ -55,3 +55,6 @@ jxl-oxide = { workspace = true }
proptest = { workspace = true }
# Lossless synthetic HEIC files for the `heif` feature's tests.
lightcraft-heif = { workspace = true, features = ["testdata"] }
+
+[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
+dhat = { workspace = true }
diff --git a/crates/codecs/README.md b/crates/codecs/README.md
index 6ea5866b7a..5902243fc0 100644
--- a/crates/codecs/README.md
+++ b/crates/codecs/README.md
@@ -73,6 +73,10 @@ ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB,
Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc`
emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding.
+Before colour conversion, positive-infinity float samples become 1.0; NaN and negative infinity
+become 0.0. This also applies to alpha. Finite samples keep their existing range: matrix/TRC paths
+preserve values above 1.0 and below 0.0, while CMS inputs and alpha remain bounded to 0..1.
+
## Dependencies and licences
All pure Rust, verified with `cargo info` / `cargo tree`:
diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs
index 02652db5ae..836aed5482 100644
--- a/crates/codecs/src/convert.rs
+++ b/crates/codecs/src/convert.rs
@@ -44,11 +44,23 @@ impl Buf {
match self {
Buf::U8(v) => v[i] as f32 / 255.0,
Buf::U16(v) => v[i] as f32 / 65535.0,
- Buf::F32(v) => v[i],
+ Buf::F32(v) => normalize_float(v[i]),
}
}
}
+/// Clip non-finite device samples before colour conversion, preserving finite extended range.
+#[inline]
+fn normalize_float(value: f32) -> f32 {
+ if value == f32::INFINITY {
+ 1.0
+ } else if value.is_finite() {
+ value
+ } else {
+ 0.0
+ }
+}
+
/// Interleaved device samples as produced by a format decoder.
pub(crate) struct Raw {
pub width: usize,
@@ -218,10 +230,7 @@ fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f {
par_rows(&mut img.data, w, |y, row| {
for (x, o) in row.iter_mut().enumerate() {
let i = (y * w + x) * ch;
- let s = |c: usize| {
- let val = v[i + c];
- if val.is_finite() { val } else { 0.0 }
- };
+ let s = |c: usize| normalize_float(v[i + c]);
*o = if gray {
let l = if lin { s(0) } else { trc[1].to_linear(s(0)) };
[l, l, l]
diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs
index 26e144aed9..adf59c0aa4 100644
--- a/crates/codecs/src/jxl.rs
+++ b/crates/codecs/src/jxl.rs
@@ -8,7 +8,7 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint};
-use jxl_oxide::{AuxBoxData, JxlImage};
+use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage};
const F: Format = Format::Jxl;
@@ -16,10 +16,60 @@ fn err(e: impl std::fmt::Display) -> Error {
Error::Malformed(F, e.to_string())
}
+/// Tracked allocation budget per allowed pixel for frames, extra channels and modular trees.
+/// This bounds allocations covered by `AllocTracker`, not every allocation made by the decoder (#1517).
+const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8;
+
+/// The allocation budget never drops below this, so small images keep the decoder's fixed
+/// overhead (group buffers, entropy tables) whatever `max_pixels` is.
+const MIN_TRACKED_BYTES: u64 = 64 << 20;
+
+/// Bytes handed to the decoder at a time (the size `JxlImageBuilder::read` reads in).
+const INPUT_CHUNK: usize = 4096;
+
+/// Initializes from bounded input, checks dimensions before rendering, then loads remaining frames
+/// within a tracked allocation budget.
+fn open(bytes: &[u8], opts: &DecodeOptions) -> Result {
+ let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES);
+ let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX));
+ let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit();
+ // Initialization can load already-buffered frame data, so small feeds limit work before
+ // dimension checking.
+ let mut rest = bytes;
+ let mut window = INPUT_CHUNK;
+ let mut img = loop {
+ let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?;
+ rest = rest.get(consumed..).unwrap_or_default();
+ match uninit.try_init().map_err(err)? {
+ InitializeResult::Initialized(img) => break img,
+ InitializeResult::NeedMoreData(more) => uninit = more,
+ }
+ if consumed == 0 {
+ // the parser wants more than the window holds at once
+ if window >= rest.len() {
+ return Err(err("truncated JPEG XL header"));
+ }
+ window = window.saturating_mul(2);
+ }
+ };
+ check_size(F, img.width() as u64, img.height() as u64, opts)?;
+ // Match `JxlImageBuilder::read`: bounded feeds until the final frame, not merely the first
+ // keyframe. `feed_bytes` copies codestream data into an untracked Vec, including any tail
+ // after the last frame; feeding the remaining file at once bypasses the allocation budget.
+ while !img.is_loading_done() && !rest.is_empty() {
+ let consumed = img.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(err)?;
+ if consumed == 0 {
+ break;
+ }
+ rest = rest.get(consumed..).unwrap_or_default();
+ }
+ img.finalize().map_err(err)?;
+ Ok(img)
+}
+
pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
- let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?;
+ let mut img = open(bytes, opts)?;
let (w, h) = (img.width(), img.height());
- check_size(F, w as u64, h as u64, opts)?;
let header = img.image_header();
let bits = header.metadata.bit_depth.bits_per_sample() as u8;
let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. });
diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs
index b077c214a0..1bbfa8e6ce 100644
--- a/crates/codecs/src/tiff_codec.rs
+++ b/crates/codecs/src/tiff_codec.rs
@@ -95,6 +95,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result {
fn limits(opts: &DecodeOptions) -> Limits {
let mut limits = Limits::default();
limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize;
+ // A strip or tile whose declared byte count exceeds this is refused: the default 128 MiB refuses a
+ // single-strip uncompressed 16-bit export of ~22 MP (#1983). Raised to the decoded image's cap
+ // (32 bytes per allowed pixel), never lowered: a strip or tile can outgrow the pixels it shows
+ // (JPEG headers, padding to the tile size).
+ limits.intermediate_buffer_size = limits.intermediate_buffer_size.max(limits.decoding_buffer_size);
limits.ifd_value_size = 32 << 20;
limits
}
diff --git a/crates/codecs/tests/float_tiff.rs b/crates/codecs/tests/float_tiff.rs
new file mode 100644
index 0000000000..fe1cd2411b
--- /dev/null
+++ b/crates/codecs/tests/float_tiff.rs
@@ -0,0 +1,128 @@
+//! Non-finite samples in procedural 32-bit float TIFFs (issue #1952, item 6).
+
+use lightcraft_codecs::{icc, *};
+
+fn tiff(samples: &[f32], channels: u8, profile: Option<&[u8]>) -> Vec {
+ encode_tiff(
+ &EncodeImage::new((samples.len() / channels as usize) as u32, 1, channels, Samples::F32(samples)),
+ TiffCompression::None,
+ &EncodeMeta { icc: profile, ..Default::default() },
+ )
+ .unwrap()
+}
+
+#[test]
+fn float_tiff_nonfinite_rgb_clips_without_losing_finite_hdr() {
+ let samples =
+ [[f32::INFINITY; 3], [f32::NAN; 3], [f32::NEG_INFINITY; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [f32::INFINITY, 0.5, f32::NAN]];
+ let bytes = tiff(samples.as_flattened(), 3, None);
+ let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
+ assert!(decoded.float);
+ assert_eq!(decoded.bit_depth, 32);
+ assert_eq!(decoded.image.data, [[1.0; 3], [0.0; 3], [0.0; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [1.0, 0.5, 0.0]]);
+ let display = decoded.to_srgb8();
+ assert_eq!(display.data[0], [255; 4]);
+ for pixel in &display.data[1..4] {
+ assert_eq!(*pixel, [0, 0, 0, 255]);
+ }
+ assert_eq!(display.data[6], [255, 188, 0, 255]);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+
+ // A box-filtered pair must average clipped white with its ordinary neighbor.
+ let pair = tiff(&[f32::INFINITY, f32::INFINITY, f32::INFINITY, 0.25, 0.25, 0.25], 3, None);
+ let small = decode(&pair, DecodeOptions::fit(1, 1)).unwrap();
+ assert_eq!(small.image.data, [[0.625; 3]]);
+}
+
+#[test]
+fn float_tiff_nonfinite_gray_and_icc_curves_match_clipped_samples() {
+ let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 1.0, 2.0, -0.25];
+ let clipped = [1.0, 0.0, 0.0, 0.25, 1.0, 2.0, -0.25];
+ let profiles = [
+ None,
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Linear)),
+ Some(icc::write_named(NamedSpace::Srgb)),
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Gamma(2.0))),
+ Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Table(vec![0, 16384, 65535]))),
+ Some(moxcms::ColorProfile::new_gray_with_gamma(2.0).encode().unwrap()),
+ Some(b"invalid ICC profile".to_vec()),
+ ];
+ for channels in [1, 3] {
+ let samples: Vec<_> = samples.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
+ let clipped: Vec<_> = clipped.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
+ for profile in &profiles {
+ let profile = profile.as_deref();
+ let decoded = decode(&tiff(&samples, channels, profile), DecodeOptions::default()).unwrap();
+ let reference = decode(&tiff(&clipped, channels, profile), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.grayscale, channels == 1);
+ assert_eq!(decoded.image, reference.image, "channels={channels}, space={:?}", decoded.space);
+ assert_eq!(decoded.to_srgb8(), reference.to_srgb8());
+ assert!(decoded.image.data[5].iter().all(|v| *v > 1.0), "finite HDR survives the curve");
+ assert!(decoded.image.data[6].iter().all(|v| *v < 0.0), "finite negative samples survive the curve");
+ }
+ }
+}
+
+#[test]
+fn float_tiff_nonfinite_alpha_is_finite_and_bounded() {
+ let samples = [
+ [f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY],
+ [f32::NAN, f32::NEG_INFINITY, 0.5, f32::NAN],
+ [0.25, 0.5, 2.0, f32::NEG_INFINITY],
+ [2.0, 3.0, 4.0, 0.5],
+ ];
+ let decoded = decode(&tiff(samples.as_flattened(), 4, None), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.alpha.as_ref().unwrap().data, [1.0, 0.0, 0.0, 0.5]);
+ assert_eq!(decoded.image.data, [[1.0; 3], [0.0, 0.0, 0.5], [0.25, 0.5, 2.0], [2.0, 3.0, 4.0]]);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+
+ // Associated alpha takes the same path before unpremultiplication.
+ let mut cursor = std::io::Cursor::new(Vec::new());
+ {
+ use tiff::encoder::{TiffEncoder, colortype};
+ let mut encoder = TiffEncoder::new(&mut cursor).unwrap();
+ let mut image = encoder.new_image::(2, 1).unwrap();
+ image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
+ image.write_data(&[f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.5, 1.0, 2.0, 3.0, 0.5]).unwrap();
+ }
+ let decoded = decode(&cursor.into_inner(), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.alpha.as_ref().unwrap().data, [0.5, 0.5]);
+ assert_eq!(decoded.image.data, [[2.0, 0.0, 0.0], [2.0, 4.0, 6.0]]);
+}
+
+fn lut_profile() -> Vec {
+ use moxcms::{ColorProfile, LutMultidimensionalType, LutWarehouse, Matrix3d, ToneReprCurve, Vector3d, Xyzd};
+ let mut profile = ColorProfile::new_srgb();
+ profile.cicp = None;
+ profile.red_colorant = Xyzd::default();
+ profile.green_colorant = Xyzd::default();
+ profile.blue_colorant = Xyzd::default();
+ profile.red_trc = None;
+ profile.green_trc = None;
+ profile.blue_trc = None;
+ // A procedural mAB profile without a CLUT: linear RGB to D50 XYZ, then identity curves.
+ profile.lut_a_to_b_colorimetric = Some(LutWarehouse::Multidimensional(LutMultidimensionalType {
+ num_input_channels: 3,
+ num_output_channels: 3,
+ grid_points: [0; 16],
+ clut: None,
+ a_curves: vec![],
+ m_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
+ matrix: Matrix3d { v: NamedSpace::Srgb.to_xyz_d50().0.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
+ b_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
+ bias: Vector3d::default(),
+ }));
+ profile.encode().unwrap()
+}
+
+#[test]
+fn float_tiff_nonfinite_cms_samples_match_clipped_samples() {
+ let profile = lut_profile();
+ let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 0.5, 0.75];
+ let clipped = [1.0, 0.0, 0.0, 0.25, 0.5, 0.75];
+ let decoded = decode(&tiff(&samples, 3, Some(&profile)), DecodeOptions::default()).unwrap();
+ let reference = decode(&tiff(&clipped, 3, Some(&profile)), DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.space.origin, SpaceOrigin::IccCms);
+ assert_eq!(decoded.image, reference.image);
+ assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
+}
diff --git a/crates/codecs/tests/jxl_streaming.rs b/crates/codecs/tests/jxl_streaming.rs
new file mode 100644
index 0000000000..298034656c
--- /dev/null
+++ b/crates/codecs/tests/jxl_streaming.rs
@@ -0,0 +1,98 @@
+#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+
+use lightcraft_codecs::exif::minimal_exif;
+use lightcraft_codecs::*;
+use lightcraft_raster::Rgba8;
+
+fn codestream(bytes: &[u8]) -> Vec {
+ if bytes.starts_with(b"\xff\x0a") {
+ return bytes.to_vec();
+ }
+ let mut raw = Vec::new();
+ let mut at = 12;
+ while at + 8 <= bytes.len() {
+ let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
+ let end = if len == 0 { bytes.len() } else { at + len };
+ match &bytes[at + 4..at + 8] {
+ b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
+ b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
+ _ => {}
+ }
+ at = end;
+ }
+ assert!(raw.starts_with(b"\xff\x0a"));
+ raw
+}
+
+fn box_bytes(out: &mut Vec, kind: &[u8; 4], data: &[u8]) {
+ out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap().to_be_bytes());
+ out.extend_from_slice(kind);
+ out.extend_from_slice(data);
+}
+
+#[test]
+fn metadata_in_final_feed_matches_builder() {
+ let pixels = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
+ let encoded = encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let raw = codestream(&encoded);
+ let exif = minimal_exif(1);
+ let mut exif_box = vec![0; 4]; // TIFF header offset, relative to the end of this field
+ exif_box.extend_from_slice(&exif);
+ // Split the codestream across two feeds, with Exif and XMP following its last frame.
+ let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec();
+ box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl ");
+ let split = raw.len() / 2;
+ let mut part = 0u32.to_be_bytes().to_vec();
+ part.extend_from_slice(&raw[..split]);
+ box_bytes(&mut bytes, b"jxlp", &part);
+ let padding = vec![0; 4096 - bytes.len() - 8];
+ box_bytes(&mut bytes, b"free", &padding);
+ let mut part = 0x8000_0001u32.to_be_bytes().to_vec();
+ part.extend_from_slice(&raw[split..]);
+ box_bytes(&mut bytes, b"jxlp", &part);
+ box_bytes(&mut bytes, b"Exif", &exif_box);
+ box_bytes(&mut bytes, b"xml ", b"");
+ assert!(bytes.len() < 8192, "metadata must fit in the completing input chunk");
+ let old = jxl_oxide::JxlImage::builder().read(bytes.as_slice()).unwrap();
+ let old_exif = match old.aux_boxes().first_exif().unwrap() {
+ jxl_oxide::AuxBoxData::Data(ex) => ex.payload().to_vec(),
+ _ => panic!("builder must preserve Exif in its final feed"),
+ };
+ let old_xml = match old.aux_boxes().first_xml() {
+ jxl_oxide::AuxBoxData::Data(xml) => xml.to_vec(),
+ _ => panic!("builder must preserve XMP in its final feed"),
+ };
+ let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
+ assert_eq!(decoded.exif.as_deref(), Some(old_exif.as_slice()));
+ assert_eq!(decoded.xmp.as_deref(), Some(std::str::from_utf8(&old_xml).unwrap()));
+ assert_eq!(decoded.to_srgb8(), pixels);
+}
+
+#[test]
+fn progressive_truncation_matches_builder() {
+ let pixels = Rgba8::from_fn(96, 64, |x, y| {
+ let mut seed = (y * 96 + x + 1) as u32;
+ let mut rgb = [0; 4];
+ for channel in &mut rgb[..3] {
+ seed ^= seed << 13;
+ seed ^= seed >> 17;
+ seed ^= seed << 5;
+ *channel = seed as u8;
+ }
+ rgb[3] = 255;
+ rgb
+ });
+ for lossless in [false, true] {
+ let encoded =
+ encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless, progressive: true, ..Default::default() }, &EncodeMeta::default())
+ .unwrap();
+ let raw = codestream(&encoded);
+ assert!(raw.len() > 4096, "fixture must exercise post-header feeds");
+ for cut in [0, 1, 2, 12, 4095, 4096, 4097, raw.len() / 2, raw.len() - 1, raw.len()] {
+ let bytes = &raw[..cut];
+ // `finalize` permits partial images: rendering determines whether frame 0 is usable.
+ let old_ok = jxl_oxide::JxlImage::builder().read(bytes).is_ok_and(|image| image.render_frame(0).is_ok());
+ assert_eq!(decode(bytes, DecodeOptions::default()).is_ok(), old_ok, "lossless={lossless}, cut={cut}/{}", raw.len());
+ }
+ }
+}
diff --git a/crates/codecs/tests/jxl_tail_heap.rs b/crates/codecs/tests/jxl_tail_heap.rs
new file mode 100644
index 0000000000..f2200e8621
--- /dev/null
+++ b/crates/codecs/tests/jxl_tail_heap.rs
@@ -0,0 +1,43 @@
+#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+
+use lightcraft_codecs::*;
+use lightcraft_raster::Rgba8;
+
+#[global_allocator]
+static ALLOC: dhat::Alloc = dhat::Alloc;
+
+fn codestream(bytes: &[u8]) -> Vec {
+ if bytes.starts_with(b"\xff\x0a") {
+ return bytes.to_vec();
+ }
+ let mut raw = Vec::new();
+ let mut at = 12;
+ while at + 8 <= bytes.len() {
+ let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
+ let end = if len == 0 { bytes.len() } else { at + len };
+ match &bytes[at + 4..at + 8] {
+ b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]),
+ b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]),
+ _ => {}
+ }
+ at = end;
+ }
+ assert!(raw.starts_with(b"\xff\x0a"));
+ raw
+}
+
+#[test]
+fn tiny_jxl_tail_does_not_allocate_a_tail_sized_buffer() {
+ let image = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]);
+ let encoded = encode_jxl(&EncodeImage::rgba8(&image), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let mut raw = codestream(&encoded);
+ let opts = DecodeOptions { max_pixels: 64, ..Default::default() };
+ decode(&raw, opts).unwrap();
+ raw.resize(raw.len() + (1 << 20), 0);
+ let _profiler = dhat::Profiler::builder().testing().build();
+ let decoded = decode(&raw, opts).unwrap();
+ assert_eq!((decoded.width, decoded.height), (8, 8));
+ let peak = dhat::HeapStats::get().max_bytes;
+ eprintln!("peak heap bytes: {peak}");
+ assert!(peak < (128 << 10), "tiny image used {peak} heap bytes");
+}
diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs
index af689ae5d7..d802366c3f 100644
--- a/crates/codecs/tests/roundtrip.rs
+++ b/crates/codecs/tests/roundtrip.rs
@@ -263,6 +263,90 @@ fn tiff_cmyk_and_orientation() {
assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}");
}
+/// An uncompressed 16-bit RGB export in a single strip, the shape of the file in #1983 (3923 × 5877,
+/// 138 MB). Its one strip is larger than the `tiff` crate's default 128 MiB intermediate buffer, so
+/// the thumbnail (and the full decode) failed with "decoder limits exceeded".
+#[test]
+fn tiff_single_strip_larger_than_128_mib() {
+ use tiff::encoder::{TiffEncoder, colortype};
+ let (w, h) = (3923u32, 5877u32);
+ let mut cur = std::io::Cursor::new(Vec::new());
+ {
+ let mut enc = TiffEncoder::new(&mut cur).unwrap();
+ let mut im = enc.new_image::(w, h).unwrap();
+ im.rows_per_strip(h).unwrap();
+ im.write_data(&vec![0x8000u16; (w * h * 3) as usize]).unwrap();
+ }
+ let bytes = cur.into_inner();
+ assert!(bytes.len() > 128 << 20);
+ let t = decode_thumbnail(&bytes, 256).unwrap();
+ assert_eq!((t.source_width, t.source_height), (w, h));
+ let px = t.image.get(10, 10);
+ assert!(px[0].abs_diff(128) <= 2 && px[0] == px[1] && px[1] == px[2], "{px:?}");
+}
+
+/// A little-endian TIFF with one IFD of single-value `(tag, type, value)` entries, followed by `data`.
+fn ifd_tiff(entries: &[(u16, u16, u32)], data: &[u8]) -> Vec {
+ let mut b = b"II*\0".to_vec();
+ b.extend(8u32.to_le_bytes());
+ b.extend((entries.len() as u16).to_le_bytes());
+ for &(tag, ty, value) in entries {
+ b.extend(tag.to_le_bytes());
+ b.extend(ty.to_le_bytes());
+ b.extend(1u32.to_le_bytes());
+ b.extend(value.to_le_bytes());
+ }
+ b.extend(0u32.to_le_bytes());
+ b.extend_from_slice(data);
+ b
+}
+
+/// A strip or tile can be larger than the pixels it shows (a JPEG header, padding to the tile size), so
+/// a small `max_pixels` must not shrink the intermediate buffer below the `tiff` crate's 128 MiB
+/// default. Both files below were refused with "decoder limits exceeded" when the buffer was capped at
+/// 32 bytes per allowed pixel. Fixtures from AlexChambers' review of #1989.
+#[test]
+fn tiff_small_pixel_budget_reads_headers_and_padded_tiles() {
+ let opts = |max_pixels| DecodeOptions { max_size: None, max_pixels };
+
+ // 1 × 1 gray, one JPEG-compressed strip (~400 bytes for one pixel), with a one-pixel budget.
+ let mut jpeg = Vec::new();
+ jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[128], 1, 1, jpeg_encoder::ColorType::Luma).unwrap();
+ let at = 8 + 2 + 10 * 12 + 4;
+ let entries = [
+ (256, 4, 1),
+ (257, 4, 1),
+ (258, 3, 8),
+ (259, 3, 7),
+ (262, 3, 1),
+ (273, 4, at),
+ (277, 3, 1),
+ (278, 4, 1),
+ (279, 4, jpeg.len() as u32),
+ (284, 3, 1),
+ ];
+ let d = decode(&ifd_tiff(&entries, &jpeg), opts(1)).unwrap();
+ assert_eq!((d.image.width, d.image.height), (1, 1));
+
+ // 16 × 16 gray stored in one 256 × 256 tile (64 KiB), with a budget of exactly 256 pixels.
+ let at = 8 + 2 + 11 * 12 + 4;
+ let entries = [
+ (256, 4, 16),
+ (257, 4, 16),
+ (258, 3, 8),
+ (259, 3, 1),
+ (262, 3, 1),
+ (277, 3, 1),
+ (284, 3, 1),
+ (322, 4, 256),
+ (323, 4, 256),
+ (324, 4, at),
+ (325, 4, 65536),
+ ];
+ let d = decode(&ifd_tiff(&entries, &[128; 65536]), opts(256)).unwrap();
+ assert_eq!((d.image.width, d.image.height), (16, 16));
+}
+
/// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and
/// already packed into `strip`, one padded row after another.
fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec {
@@ -734,6 +818,21 @@ fn jxl_lossless_exact_with_metadata() {
assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image));
}
+/// #1517: the pixel limit rejects an oversized image; selected truncations never panic.
+#[test]
+#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))]
+fn jxl_size_limit_and_truncation() {
+ let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap();
+ let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() });
+ assert!(matches!(r, Err(Error::TooLarge(97, 61))), "{r:?}");
+ let d = decode(&b, DecodeOptions { max_pixels: 97 * 61, ..Default::default() }).unwrap();
+ assert_eq!((d.width, d.height), (97, 61));
+ for cut in [0, 1, 2, 5, 12, 40, b.len() / 2, b.len() - 1] {
+ let _ = decode(&b[..cut], DecodeOptions::default());
+ }
+ assert!(decode(&b[..12.min(b.len())], DecodeOptions::default()).is_err());
+}
+
#[test]
#[cfg(feature = "jxl")]
fn jxl_lossy_quality_and_effort() {
diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs
index 855c3d0499..89fe93c9fd 100644
--- a/crates/color/src/lib.rs
+++ b/crates/color/src/lib.rs
@@ -180,8 +180,8 @@ pub struct Rgb8(pub u8, pub u8, pub u8);
impl Rgb8 {
pub fn from_hex(s: &str) -> Option {
- let s = s.trim_start_matches('#');
- if s.len() != 6 {
+ let s = s.strip_prefix('#').unwrap_or(s);
+ if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let v = u32::from_str_radix(s, 16).ok()?;
@@ -196,6 +196,15 @@ impl Rgb8 {
mod tests {
use super::*;
+ #[test]
+ fn rgb8_from_hex_rejects_signs_and_extra_hashes() {
+ assert_eq!(Rgb8::from_hex("#0f0f0f"), Some(Rgb8(15, 15, 15)));
+ assert_eq!(Rgb8::from_hex("ff8000"), Some(Rgb8(255, 128, 0)));
+ for bad in ["+12345", "#+12345", "#+f+f+f", "-12345", "##123456", "#12345", ""] {
+ assert_eq!(Rgb8::from_hex(bad), None, "{bad}");
+ }
+ }
+
#[test]
fn srgb_matrix_matches_standard() {
let m = SRGB.to_xyz();
diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs
index 987a859b97..4314ed8c3d 100644
--- a/crates/denoise-core/src/run.rs
+++ b/crates/denoise-core/src/run.rs
@@ -14,7 +14,7 @@ use crate::manifest::Gain;
use crate::tiles::{starts, weight};
/// Most pixels one picture may have (guards the allocations that follow from its size).
-pub const MAX_PIXELS: usize = 100_000_000;
+pub const MAX_PIXELS: usize = 110_000_000;
/// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from.
const MIN_SIGNAL: f32 = 0.003;
@@ -642,4 +642,19 @@ mod tests {
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_))));
assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_))));
}
+
+ #[test]
+ fn accepts_102_mp_inputs_and_rejects_inputs_above_the_limit() {
+ let layout = Layout { dx: 0, dy: 0 };
+ let model = Mock { tile: 32, scale: 1.0 };
+ let p = params(32, 8, Gain::None);
+ assert!(matches!(
+ denoise_bayer(&[], 11_648, 8_736, layout, &model, &p, &Control::default()),
+ Err(Error::Input(message)) if message == "the mosaic does not have width × height samples"
+ ));
+ assert!(matches!(
+ denoise_bayer(&[], MAX_PIXELS + 1, 1, layout, &model, &p, &Control::default()),
+ Err(Error::Input(message)) if message == "the picture has no size, or is too large"
+ ));
+ }
}
diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs
index f376b3c67c..4631e5e498 100644
--- a/crates/develop/src/lib.rs
+++ b/crates/develop/src/lib.rs
@@ -41,7 +41,7 @@ impl DevelopSettings {
/// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that
/// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a
/// partial from before the scale existed, or one that never said, must not become Kelvin by landing on
- /// Kelvin settings.
+ /// Kelvin settings. Out-of-range values are brought back into range ([`DevelopSettings::keep_in_range`]).
pub fn merged(&self, partial: &Value) -> Result {
let mut v = self.to_json();
presets::deep_merge(&mut v, partial);
@@ -52,9 +52,34 @@ impl DevelopSettings {
{
out.wb.scale = WbScale::Legacy;
}
+ out.keep_in_range(self);
Ok(out)
}
+ /// What crs XMP import does to the values it reads, for settings that arrive as JSON (presets, `develop.merge`,
+ /// `render --settings`; issue #1946): every slider clamped through its control spec, point curves in 0..1, and a
+ /// crop with no area left as it was in `before`. Unrotated, the crop is clipped to the photo first; a turned crop
+ /// may reach past 0..1 and is left to the crop tools, which know the photo's size.
+ fn keep_in_range(&mut self, before: &DevelopSettings) {
+ for c in controls::CONTROLS {
+ if let Some(v) = controls::get(self, c.id) {
+ controls::set(self, c.id, v);
+ }
+ }
+ let c = &mut self.curve;
+ for p in [&mut c.master, &mut c.red, &mut c.green, &mut c.blue].into_iter().flatten() {
+ *p = lightcraft_geom::Point::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0));
+ }
+ let g = &mut self.crop.geometry;
+ if g.angle == 0.0 {
+ g.rect = g.rect.intersect(&lightcraft_geom::Rect::UNIT);
+ }
+ if g.rect.is_empty() {
+ let old = before.crop.geometry.rect;
+ g.rect = if old.is_empty() { lightcraft_geom::Rect::UNIT } else { old };
+ }
+ }
+
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
pub fn hash64(&self) -> u64 {
let s = serde_json::to_string(self).unwrap_or_default();
@@ -255,6 +280,30 @@ mod tests {
assert_eq!(m.light.exposure, 0.0);
}
+ #[test]
+ fn merged_settings_are_kept_in_range() {
+ // issue #1946: `develop.merge`, `render --settings` and presets stored whatever the JSON said
+ let mut base = DevelopSettings::default();
+ base.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.9);
+ let m = base
+ .merged(&json!({"light": {"exposure": 1e308}, "vignette": {"amount": -1e308}, "geometry": {"scale": 0}, "optics": {"distortion": 1e308}}))
+ .unwrap();
+ assert_eq!((m.light.exposure, m.vignette.amount, m.geometry.scale, m.optics.distortion), (5.0, -100.0, 50.0, 100.0));
+ // point curves stay in 0..1, as an imported XMP curve does
+ let m = base.merged(&json!({"curve": {"master": [{"x": -3, "y": 0}, {"x": 1, "y": 1e308}]}})).unwrap();
+ assert_eq!(m.curve.master, vec![lightcraft_geom::Point::new(0.0, 0.0), lightcraft_geom::Point::new(1.0, 1.0)]);
+ // a crop with no area is left out, as XMP import leaves out one whose edges are inverted
+ for rect in
+ [json!({"x0": 0.9, "x1": 0.1, "y0": 0, "y1": 1}), json!({"x0": 1e308, "x1": -1e308, "y0": 0, "y1": 1}), json!({"x0": 0.5, "x1": 0.5})]
+ {
+ let m = base.merged(&json!({"crop": {"geometry": {"rect": rect}}})).unwrap();
+ assert_eq!(m.crop.geometry.rect, base.crop.geometry.rect, "{rect}");
+ }
+ // unrotated, the crop stays inside the photo
+ let m = base.merged(&json!({"crop": {"geometry": {"rect": {"x0": -1e308, "y0": 0.25, "x1": 1e308, "y1": 0.75}}}})).unwrap();
+ assert_eq!(m.crop.geometry.rect, lightcraft_geom::Rect::new(0.0, 0.25, 1.0, 0.75));
+ }
+
#[test]
fn unedited_and_reset() {
let mut s = DevelopSettings::default();
diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs
index 85f27d321b..b3edc801cd 100644
--- a/crates/develop/src/presets.rs
+++ b/crates/develop/src/presets.rs
@@ -184,17 +184,11 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
}
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
-/// through the control specs.
+/// through the control specs, as every merge does ([`DevelopSettings::merged`]).
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) };
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
- let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
- for c in crate::controls::CONTROLS {
- if let Some(v) = crate::controls::get(&out, c.id) {
- crate::controls::set(&mut out, c.id, v);
- }
- }
- out
+ s.merged(&patch).unwrap_or_else(|_| s.clone())
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index ed20cd6057..091efe0661 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -9,6 +9,8 @@ description = "LightCraft engine: session, command registry, history, render job
workspace = true
[dependencies]
+# Paths stored in a Lightroom catalog are followed only to local files or the catalog's share.
+craft-docpath = { workspace = true }
lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs
index 676519d297..030cb6e933 100644
--- a/crates/engine/src/cmd/cull.rs
+++ b/crates/engine/src/cmd/cull.rs
@@ -16,7 +16,7 @@ const SAME: f32 = 0.93;
fn secs(iso: &str) -> Option {
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
- Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
+ Some(d.unix_seconds())
}
fn analyze(s: &mut Session, p: &Value) -> Result {
diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs
index 7b0e1e5620..860cf089f5 100644
--- a/crates/engine/src/cmd/library.rs
+++ b/crates/engine/src/cmd/library.rs
@@ -1488,7 +1488,7 @@ pub fn specs() -> Vec {
always, |_, p| {
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
- Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings}))
+ Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings}))
}),
cmd!(
"library.importLightroom",
diff --git a/crates/engine/src/cmd/library/tests_relative_ratings.rs b/crates/engine/src/cmd/library/tests_relative_ratings.rs
index 1a1b8b23c8..8af63cd9b0 100644
--- a/crates/engine/src/cmd/library/tests_relative_ratings.rs
+++ b/crates/engine/src/cmd/library/tests_relative_ratings.rs
@@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() {
std::fs::create_dir_all(&dir).unwrap();
// Metadata-only fixtures: no import, image decoding or model/service dependency.
for id in 1..=4 {
+ std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap();
s.catalog
.apply(Op::SetFile {
id: PhotoId(id),
diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs
index 66ddfe9fbc..339b20b905 100644
--- a/crates/engine/src/cmd/manage.rs
+++ b/crates/engine/src/cmd/manage.rs
@@ -9,8 +9,13 @@ use crate::Result;
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec, String, usize)> {
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
- let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
- Ok((s.targets(p)?, template, start))
+ let start = p.get("start").and_then(Value::as_u64).unwrap_or(1);
+ let ids = s.targets(p)?;
+ // every number of the sequence `start..start + n` must fit
+ let last = start.checked_add((ids.len() as u64).saturating_sub(1));
+ let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok());
+ let start = start.ok_or_else(|| bad(c, "start out of range"))?;
+ Ok((ids, template, start))
}
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs
index 05a8518933..26f5d9bc45 100644
--- a/crates/engine/src/cmd/print.rs
+++ b/crates/engine/src/cmd/print.rs
@@ -324,7 +324,7 @@ pub fn specs() -> Vec {
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
always, get),
cmd!(query "print.set", "Set Print Settings", [], None,
- "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
+ "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
always, set),
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
cmd!(query "print.cell.add", "Add Print Cell", [], None,
diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs
index f5384e23dc..848251c65f 100644
--- a/crates/engine/src/crs_masks.rs
+++ b/crates/engine/src/crs_masks.rs
@@ -99,6 +99,21 @@ fn adjust(c: &XmpValue) -> Value {
Value::Object(a)
}
+/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set:
+/// reported, never dropped silently.
+fn unmapped_local(c: &XmpValue) -> Vec {
+ let XmpValue::Struct(m) = c else { return Vec::new() };
+ m.keys()
+ .filter_map(|k| {
+ let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?;
+ // 100 = "curve saturation unchanged", written with every correction
+ let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 };
+ let set = num(c, k).is_some_and(|v| v != neutral);
+ (set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string())
+ })
+ .collect()
+}
+
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
/// luminance on −8..+4 EV mapped to 0..1.
fn lightness_to_range(l: f64) -> f64 {
@@ -214,8 +229,12 @@ fn component(m: &XmpValue, container: &str, aspect: f64) -> Result {
let q: Vec = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
+ // full strength at white (1, the top of their scale) takes in everything brighter: ours
+ // goes on above scene white, so that end stays open instead of cutting off at 0 EV
+ let open_top = c >= 1.0;
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
- json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
+ let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) };
+ json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather})
}
Some("3") => {
let q: Vec = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
@@ -252,6 +271,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec, Vec) {
if parts.is_empty() {
continue;
}
+ skipped.extend(unmapped_local(c));
// brush dabs of one correction are one brush component
let mut merged: Vec = Vec::new();
for p in parts {
@@ -506,6 +526,28 @@ mod tests {
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
}
+ #[test]
+ fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
+ // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
+ let x = r#"
+
+
+
+
+
+
+
+
+"#;
+ let d = lightcraft_meta::parse_xmp(x).unwrap();
+ let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
+ let s = DevelopSettings::default().merged(&partial).expect("valid settings");
+ let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
+ assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
+ assert_eq!(s.masks[0].adjust.clarity, -30.0);
+ assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
+ }
+
#[test]
fn lrtemplate_corrections() {
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs
new file mode 100644
index 0000000000..c2b0e9f690
--- /dev/null
+++ b/crates/engine/src/doc_path.rs
@@ -0,0 +1,63 @@
+//! Paths stored in a file LightCraft reads but the user did not choose path by path, such as the
+//! originals a Lightroom catalog lists.
+//!
+//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from the
+//! document's folder, and only local files, or files on the network share the document itself is
+//! on, are looked at (see `craft_docpath`). A path that is not followed is handled like a missing
+//! file.
+
+use std::fmt;
+use std::path::{Path, PathBuf};
+
+pub use craft_docpath::Refused;
+
+/// Why a stored path is not followed.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NotFollowed {
+ /// The path is on another computer (other than the document's share), names a device, is a
+ /// URL, or is not a usable path.
+ Refused(Refused),
+ /// The path is not an absolute path on this computer, such as a Windows drive path read on
+ /// macOS or Linux.
+ NotHere,
+}
+
+impl fmt::Display for NotFollowed {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Refused(r) => r.fmt(f),
+ Self::NotHere => f.write_str("the path is not a path on this computer"),
+ }
+ }
+}
+
+impl std::error::Error for NotFollowed {}
+
+/// The file to look at for `stored`, a path or `file:` URL stored in the document at `document`,
+/// or why it is not followed. A relative `document` is taken from the working directory, where it
+/// was opened from.
+pub fn resolve(stored: &str, document: Option<&Path>) -> Result {
+ let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() });
+ let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?;
+ // A path that is still relative would be taken from the working directory, which the
+ // document does not choose.
+ if path.is_relative() {
+ return Err(NotFollowed::NotHere);
+ }
+ Ok(path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn relative_paths_are_taken_from_the_document_folder_and_other_computers_are_refused() {
+ let doc = std::env::temp_dir().join("lrcat").join("catalog.lrcat");
+ assert_eq!(resolve("Photos/a.jpg", Some(&doc)), Ok(std::env::temp_dir().join("lrcat").join("Photos").join("a.jpg")));
+ assert_eq!(resolve("a.jpg", None), Err(NotFollowed::NotHere));
+ assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
+ assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
+ assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device)));
+ }
+}
diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs
index d294ed4d98..e9d1cbc585 100644
--- a/crates/engine/src/export.rs
+++ b/crates/engine/src/export.rs
@@ -1310,7 +1310,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
let exif = meta.map(lightcraft_meta::write_exif);
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
- let e = EncodeImage::rgba8(&img);
+ // Opaque photo exports need no alpha channel (#1670). Keep real transparency for
+ // callers of this encoder, and leave unrelated formats on their existing path.
+ let rgb: Option> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp)
+ && img.data.iter().all(|p| p.last() == Some(&255)))
+ .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect());
+ let e = match &rgb {
+ Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)),
+ None => EncodeImage::rgba8(&img),
+ };
let r = match o.format {
ExportFormat::Jpeg => {
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
@@ -2281,6 +2289,43 @@ mod tests {
img
}
+ /// Issue #1670: removing a redundant alpha channel preserves samples and metadata.
+ #[test]
+ fn opaque_eight_bit_exports_are_rgb() {
+ let img = test_image();
+ let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() };
+ for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
+ for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
+ let options = ExportOptions { format, tiff_compression: compression, ..Default::default() };
+ let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap();
+ let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
+ assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB");
+ assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8));
+ assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels");
+ assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice()));
+ assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test"));
+ if format == ExportFormat::Png {
+ assert_eq!(bytes[25], 2, "PNG IHDR declares RGB");
+ }
+ if format != ExportFormat::Tiff {
+ break;
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn eight_bit_exports_preserve_real_alpha() {
+ let mut img = test_image();
+ img.data[0][3] = 128;
+ for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
+ let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap();
+ let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
+ assert!(decoded.has_alpha, "{format:?} must retain real alpha");
+ assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples");
+ }
+ }
+
#[test]
fn formats_have_magic() {
let img = test_image();
diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs
index bf10e35029..17bffbc193 100644
--- a/crates/engine/src/faces_index.rs
+++ b/crates/engine/src/faces_index.rs
@@ -167,6 +167,9 @@ impl Index {
self.entries.insert((photo, key), (emb.into(), view));
}
}
+ // a half-written last record (a crash while appending): written fresh at the next save, so later records are
+ // not appended after it and shifted
+ self.rewrite = !rest.is_empty();
}
fn header(&self) -> Vec {
@@ -201,12 +204,11 @@ impl Index {
return Ok(());
}
if self.file_matches && !self.rewrite {
- let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
let mut buf = Vec::new();
for (k, e) in &self.unsaved {
buf.extend(self.record(k, e));
}
- f.write_all(&buf)?;
+ append(&path, &buf)?;
} else {
let mut buf = self.header();
for (k, e) in &self.entries {
@@ -229,6 +231,22 @@ impl Drop for Index {
}
}
+/// Append `buf` to the file at `path`. When the write fails (a full disk), the file is cut back to its old length so
+/// the part that got in does not shift what is appended later.
+fn append(path: &Path, buf: &[u8]) -> std::io::Result<()> {
+ let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
+ let old_len = f.metadata()?.len();
+ let written = f.write_all(buf);
+ if written.is_err() {
+ drop(f);
+ // an append-only handle may not truncate (Windows): cut through a writable one
+ if let Ok(w) = std::fs::OpenOptions::new().write(true).open(path) {
+ let _ = w.set_len(old_len);
+ }
+ }
+ written
+}
+
const SCAN_MAGIC: &[u8; 4] = b"LCFS";
const SCAN_VERSION: u8 = 1;
/// The detector a search was made with: a record made by another one is ignored (a better detector may find more).
@@ -325,8 +343,11 @@ impl Scanned {
let head = self.header();
let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return };
self.file_matches = true;
- // a half-written last id (a crash while appending) is ignored
- self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c)));
+ // a half-written last id (a crash while appending) is ignored, and the record is written fresh at the next save
+ // so later ids are not appended after it and shifted
+ let (ids, torn) = rest.as_chunks::<8>();
+ self.set.extend(ids.iter().map(|c| u64::from_le_bytes(*c)));
+ self.rewrite = !torn.is_empty();
}
/// Write what was added since the last save: appended when the file is this detector's, else the whole record
@@ -341,9 +362,8 @@ impl Scanned {
return Ok(());
}
if self.file_matches && !self.rewrite {
- let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
let buf: Vec = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
- f.write_all(&buf)?;
+ append(&path, &buf)?;
} else {
let mut buf = self.header();
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
@@ -487,6 +507,51 @@ mod tests {
assert!(!t.contains(1) && t.contains(2) && t.contains(3));
let _ = std::fs::remove_dir_all(&dir);
}
+
+ /// A torn last record (a crash or a full disk while appending) does not shift the records appended after it
+ /// (issue #2129).
+ #[test]
+ fn records_appended_after_a_torn_tail_stay_aligned() {
+ let dir = std::env::temp_dir().join(format!("lc-faces-torn-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin"));
+ let junk = |p: &Path| {
+ let mut f = std::fs::OpenOptions::new().append(true).open(p).unwrap();
+ f.write_all(&[0x3f; 5]).unwrap();
+ };
+ let mut a = Index::default();
+ a.reset("m@abc", 3, Some(emb.clone()));
+ a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]);
+ a.save().unwrap();
+ drop(a);
+ junk(&emb);
+ let mut b = Index::default();
+ b.reset("m@abc", 3, Some(emb.clone()));
+ b.insert(9, &rect(0.5), vec![0.5, 0.25, 0.125]);
+ b.save().unwrap();
+ drop(b);
+ let mut c = Index::default();
+ c.reset("m@abc", 3, Some(emb));
+ assert_eq!(c.len(), 2);
+ assert_eq!(c.get(7, &rect(0.1)).map(|e| e.to_vec()), Some(vec![1.0, 0.0, 0.5]));
+ assert_eq!(c.get(9, &rect(0.5)).map(|e| e.to_vec()), Some(vec![0.5, 0.25, 0.125]));
+ let mut s = Scanned::default();
+ s.reset(Some(scan.clone()));
+ s.insert(42);
+ s.save().unwrap();
+ drop(s);
+ junk(&scan);
+ let mut t = Scanned::default();
+ t.reset(Some(scan.clone()));
+ t.insert(43);
+ t.save().unwrap();
+ drop(t);
+ let mut u = Scanned::default();
+ u.reset(Some(scan));
+ assert!(u.contains(42) && u.contains(43) && u.len() == 2);
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
/// What the recognition commands keep between calls: the loaded model and the embeddings made with it.
diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs
index dd246cf848..31bb930fbf 100644
--- a/crates/engine/src/files.rs
+++ b/crates/engine/src/files.rs
@@ -707,8 +707,9 @@ pub(crate) fn decode_raw_preview(
covering: Option,
) -> Option {
let jpeg = stand_in_image(bytes, covering)?;
- // the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
- let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
+ // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview
+ // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders
+ let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]);
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
Some(space) => {
let space = match space {
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 8a282ce3e9..25e6ef52f8 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -29,6 +29,7 @@ pub mod demo;
pub mod denoise;
pub mod devices;
pub mod display;
+pub mod doc_path;
mod errors;
pub mod export;
pub mod face_download;
@@ -56,6 +57,7 @@ pub mod media;
pub mod memory;
pub mod merge;
mod model_download;
+pub mod open_path;
pub mod originals;
mod pdf;
pub mod preset_import;
@@ -298,6 +300,10 @@ pub struct Session {
pub xmp: sidecar::XmpPrefs,
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
pub last_export: Option,
+ /// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`,
+ /// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses
+ /// them. Persisted in prefs.json.
+ pub last_merge: std::collections::BTreeMap,
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
pub export_presets: Vec,
/// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives
@@ -459,6 +465,7 @@ impl Session {
library_identity: Arc::new(()),
xmp: sidecar::XmpPrefs::default(),
last_export: None,
+ last_merge: Default::default(),
export_presets: Vec::new(),
print: Default::default(),
print_templates: Vec::new(),
diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs
index 51655aea5a..e91f0bfff0 100644
--- a/crates/engine/src/library.rs
+++ b/crates/engine/src/library.rs
@@ -207,6 +207,8 @@ struct PrefsFile {
/// Parameters of the last export (for Export with Previous).
#[serde(alias = "last_export")]
last_export: Option,
+ /// Options of the last Photo Merge, per command.
+ last_merge: std::collections::BTreeMap,
/// The user's export presets.
export_presets: Vec,
/// Print templates.
@@ -492,6 +494,7 @@ impl Session {
let prefs = settings.read::(files.as_mut(), "prefs.json").unwrap_or_default();
self.xmp = prefs.xmp;
self.last_export = prefs.last_export;
+ self.last_merge = prefs.last_merge;
self.export_presets = prefs.export_presets;
self.print_templates = prefs.print_templates;
self.metadata_presets = prefs.metadata_presets;
@@ -746,6 +749,7 @@ impl Session {
let v = serde_json::to_vec_pretty(&PrefsFile {
xmp: self.xmp,
last_export: self.last_export.clone(),
+ last_merge: self.last_merge.clone(),
export_presets: self.export_presets.clone(),
print_templates: self.print_templates.clone(),
metadata_presets: self.metadata_presets.clone(),
diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs
index 0dceb421d0..5133fc80c1 100644
--- a/crates/engine/src/lightroom_archive.rs
+++ b/crates/engine/src/lightroom_archive.rs
@@ -538,6 +538,7 @@ mod tests {
keywords: vec!["Trips|Goa".into()],
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
+ original: crate::lightroom_catalog::Original::Local,
};
CatalogImport {
source: "source.lrcat".into(),
@@ -621,6 +622,7 @@ mod tests {
keywords: Vec::new(),
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
snapshots: Vec::new(),
+ original: crate::lightroom_catalog::Original::Local,
})
.collect();
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs
index 777206bcb8..5f39188a12 100644
--- a/crates/engine/src/lightroom_catalog.rs
+++ b/crates/engine/src/lightroom_catalog.rs
@@ -2,7 +2,7 @@
//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place.
//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders.
use std::collections::{BTreeMap, HashMap, HashSet};
-use std::path::{Component, Path, PathBuf};
+use std::path::{Path, PathBuf};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
@@ -146,6 +146,31 @@ pub struct CatalogPhoto {
pub keywords: Vec,
pub history: Vec,
pub snapshots: Vec,
+ /// Whether `path` may be looked at (derived when the catalog is read; not archived).
+ #[serde(skip)]
+ pub original: Original,
+}
+
+/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog
+/// as the document ([`crate::doc_path::resolve`]).
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub enum Original {
+ /// `path` is a local file path, or one on the network share the catalog is on.
+ Local,
+ /// `path` is not a path on this computer (a catalog made on another system): the photo is
+ /// kept as an offline record for later relinking, and the path is not looked at.
+ #[default]
+ NotHere,
+ /// `path` is refused (why): it is on another computer or a device, or its folder or file name
+ /// is an absolute path. The photo is reported missing and not added to the library.
+ Refused(String),
+}
+
+impl CatalogPhoto {
+ /// Whether the original is a file here; only a [`Original::Local`] path is looked at.
+ pub fn original_is_file(&self) -> bool {
+ self.original == Original::Local && Path::new(&self.path).is_file()
+ }
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -343,25 +368,36 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result {
/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from
/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the
-/// catalog's volume, resolved against the catalog's folder, if that is a folder.
+/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are
+/// looked at only when [`crate::doc_path::resolve`] follows them.
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option {
- if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() {
+ if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) {
return None;
}
- let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative);
- let mut out = PathBuf::new();
- for part in dir.components() {
- match part {
- Component::ParentDir => {
- if !out.pop() {
- return None;
- }
- }
- Component::CurDir => {}
- part => out.push(part),
- }
+ crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir())
+}
+
+/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in
+/// place of the root folder.
+fn is_absolute_part(part: &str) -> bool {
+ let bytes = part.as_bytes();
+ part.starts_with(['/', '\\'])
+ || (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':'))
+ || Path::new(part).is_absolute()
+}
+
+/// The path of an original under `root` and whether it may be looked at, resolved with the
+/// catalog at `catalog` as the document.
+fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) {
+ let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string();
+ if is_absolute_part(folder) || is_absolute_part(name) {
+ return (stored, Original::Refused("its folder or file name is an absolute path".into()));
+ }
+ match crate::doc_path::resolve(&stored, Some(catalog)) {
+ Ok(found) => (found.to_string_lossy().to_string(), Original::Local),
+ Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere),
+ Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())),
}
- out.is_dir().then_some(out)
}
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
@@ -457,6 +493,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
out
};
let mut root_paths = HashMap::new();
+ let mut refused_roots = HashSet::new();
let mut root_ids: Vec<_> = roots.keys().copied().collect();
root_ids.sort_unstable();
for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) {
@@ -469,6 +506,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
}
None => absolute.to_string(),
};
+ if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) {
+ warnings
+ .push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name")));
+ refused_roots.insert(id);
+ }
root_paths.insert(id, resolved);
}
let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?);
@@ -487,13 +529,19 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
- let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str);
+ let root_id = number(root, "id_local");
+ let root_path = root_paths.get(&root_id).map_or("", String::as_str);
let folder_path = text(folder, "pathFromRoot");
let name = text(file, "idx_filename");
if root_path.is_empty() || name.is_empty() {
return Err(format!("image {id}: incomplete original path"));
}
- let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string();
+ let (photo_path, original) = original_path(root_path, folder_path, name, path);
+ if let Original::Refused(why) = &original
+ && !refused_roots.contains(&root_id)
+ {
+ out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added"));
+ }
let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) {
Ok(s) => s,
Err(e) => {
@@ -504,19 +552,64 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
out.photos.push(CatalogPhoto {
source_id: id,
uuid: text(&image, "id_global").into(),
- path,
+ path: photo_path,
image,
settings: develops.get(&id).cloned().unwrap_or_default(),
xmp: packet,
keywords: keywords.remove(&id).unwrap_or_default(),
history: history.remove(&id).unwrap_or_default(),
snapshots: snapshots.remove(&id).unwrap_or_default(),
+ original,
});
}
+ refuse_non_media(&mut out);
ensure_read_active(cancel)?;
Ok(out)
}
+/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
+/// import's warnings.
+///
+/// The catalog decides these paths, and a record it leaves behind is one click from the system
+/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`,
+/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run
+/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not
+/// openable. A virtual copy of a refused original goes with it — it has no original of its own.
+///
+/// Called where the catalog is read and again where its photos become records, so neither route
+/// can keep one; the second pass finds nothing left to say.
+pub(crate) fn refuse_non_media(data: &mut CatalogImport) {
+ // masters first: a virtual copy may come before its master in the table
+ let refused: HashSet = data
+ .photos
+ .iter()
+ .filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path)))
+ .map(|p| p.source_id)
+ .collect();
+ if refused.is_empty() {
+ return;
+ }
+ let mut warnings = Vec::new();
+ data.photos.retain(|p| {
+ let master = number(&p.image, "masterImage");
+ // `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not
+ // take every master of the catalog with it
+ if master != 0 {
+ if !refused.contains(&master) {
+ return true;
+ }
+ warnings.push(format!("image {}: not imported; its original was not imported", p.source_id));
+ return false;
+ }
+ if !refused.contains(&p.source_id) {
+ return true;
+ }
+ warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path));
+ false
+ });
+ data.warnings.extend(warnings);
+}
+
fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
}
@@ -665,11 +758,11 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) ->
let paths: Vec<_> = data
.photos
.iter()
- .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file())
+ .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file())
.map(|p| p.path.clone())
.collect();
let missing_sources: HashSet =
- data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect();
+ data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect();
let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?;
let applied =
apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?;
@@ -692,8 +785,10 @@ pub(crate) struct ApplyPrepared<'a> {
pub(crate) undo0: usize,
}
-pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result {
+pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result {
let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context;
+ // nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135)
+ refuse_non_media(&mut data);
validate(&data).map_err(error)?;
let mut warnings = data.warnings.clone();
let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect();
@@ -713,12 +808,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
let mut photos: Vec<_> = data.photos.iter().collect();
photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);
let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect();
+ let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect();
let mut planned: HashMap = HashMap::new();
for src in photos {
let master = number(&src.image, "masterImage");
if master == 0 && missing_sources.contains(&key(&src.path)) {
missing.push(src.path.clone());
}
+ // A refused path must not become a library record the app would look at later; its
+ // virtual copies go with it.
+ if refused.contains(&src.source_id) || refused.contains(&master) {
+ continue;
+ }
let indexed = index
.photos
.get(&identity(&data, src))
@@ -940,6 +1041,7 @@ mod tests {
keywords: vec!["Travel|Goa".into()],
history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))],
snapshots: Vec::new(),
+ original: Original::Local,
};
let mut copy = photo.clone();
copy.source_id = 2;
@@ -962,6 +1064,48 @@ mod tests {
warnings: Vec::new(),
}
}
+ /// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
+ /// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual
+ /// copies go with it; the catalog's real photos still import.
+ #[test]
+ fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() {
+ let mut data = sample();
+ let mut exe = data.photos[1].clone();
+ exe.source_id = 3;
+ exe.uuid = "payload".into();
+ exe.path = "/Users/sky/Downloads/payload.exe".into();
+ exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10}));
+ let mut exe_copy = exe.clone();
+ exe_copy.source_id = 4;
+ exe_copy.uuid = "payload-copy".into();
+ exe_copy.path = "/Users/sky/Downloads/payload.exe".into();
+ exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"}));
+ // the copy comes before its master, as a catalog's table order may have it
+ data.photos.insert(0, exe_copy);
+ data.photos.push(exe);
+ for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] {
+ let mut one = data.clone();
+ one.photos.retain(|p| p.source_id < 3);
+ let mut planted = one.photos[1].clone();
+ planted.source_id = 5;
+ planted.uuid = "planted".into();
+ planted.path = bad.into();
+ planted.image = row(json!({"id_local":5,"fileFormat":"ARW"}));
+ one.photos.push(planted);
+ let mut s = crate::Session::new();
+ let r = apply(&mut s, one, false).unwrap();
+ assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}");
+ assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record");
+ }
+ let mut s = crate::Session::new();
+ let r = apply(&mut s, data, false).unwrap();
+ assert_eq!(r["photos"], 2, "only the real master and its copy: {r}");
+ assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued");
+ let warnings = r["warnings"].to_string();
+ assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}");
+ assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}");
+ }
+
#[test]
fn offline_local_master_and_copy_join_library_with_one_undo_step() {
let mut s = crate::Session::new();
@@ -1508,11 +1652,57 @@ mod tests {
// a relative path that leads nowhere keeps the recorded one
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap();
let catalog = read(&path).unwrap();
- assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
+ assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings);
std::fs::remove_dir_all(dir).unwrap();
}
+ #[test]
+ fn originals_on_other_computers_are_never_looked_at_or_added() {
+ let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(dir.join("Photos")).unwrap();
+ std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
+ let source = dir.join("source.lrcat");
+ let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()];
+ if cfg!(unix) {
+ // `///` is a local folder to Linux, but a network path to the resolver.
+ roots.push(format!("/{}/", dir.display()));
+ }
+ for root in &roots {
+ std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap();
+ let catalog = read(&source).unwrap();
+ assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]);
+ assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings);
+ let mut s = crate::Session::new();
+ let probes = Arc::new(AtomicUsize::new(0));
+ let calls = probes.clone();
+ s.media.file_probe = Some(Arc::new(move |_| {
+ calls.fetch_add(1, Ordering::Relaxed);
+ Err("a refused original must not be probed".into())
+ }));
+ let report = import(&mut s, &source, false).unwrap();
+ assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}");
+ assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}");
+ assert!(s.catalog.is_empty(), "{root}");
+ }
+ // An absolute folder path would replace the root.
+ let mut tables = photo_tables((dir.to_str().unwrap(), ""));
+ tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display()));
+ std::fs::write(&source, native_catalog(&tables)).unwrap();
+ assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_)));
+ // A root beside the catalog is followed.
+ std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
+ let catalog = read(&source).unwrap();
+ assert_eq!(catalog.photos[0].original, Original::Local);
+ assert!(catalog.photos[0].original_is_file());
+ // A root that moved is found through its path relative to the catalog.
+ std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap();
+ let catalog = read(&source).unwrap();
+ assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]);
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
#[test]
fn oversized_optional_table_warns_and_is_skipped() {
let mut main = vec![0u8; 2 * 512];
diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs
index 8de1cc806d..52a51b30f1 100644
--- a/crates/engine/src/lightroom_job.rs
+++ b/crates/engine/src/lightroom_job.rs
@@ -329,7 +329,7 @@ fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(V
if !seen.insert(key.clone()) {
continue;
}
- if Path::new(&photo.path).is_file() {
+ if photo.original_is_file() {
paths.push(photo.path.clone());
} else {
missing.insert(key);
@@ -393,6 +393,7 @@ mod tests {
keywords: Vec::new(),
history: Vec::new(),
snapshots: Vec::new(),
+ original: lightroom_catalog::Original::Local,
}],
collections: Vec::new(),
members: Vec::new(),
@@ -531,6 +532,7 @@ mod tests {
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
)])],
snapshots: Vec::new(),
+ original: lightroom_catalog::Original::Local,
}
})
.collect();
diff --git a/crates/engine/src/open_path.rs b/crates/engine/src/open_path.rs
new file mode 100644
index 0000000000..d80039729d
--- /dev/null
+++ b/crates/engine/src/open_path.rs
@@ -0,0 +1,117 @@
+//! Whether a path the library holds may be handed to the operating system.
+//!
+//! A photo's `Source::File` path is not always something a user typed: it can come from an
+//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder.
+//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever
+//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta`
+//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135).
+//!
+//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular
+//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which
+//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must
+//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the
+//! absolute path to hand over; what comes back on refusal is the sentence to show the user.
+//!
+//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may
+//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a
+//! refused original never becomes a library record in the first place.
+
+use std::path::Path;
+
+/// The absolute path to hand to an opener or external editor, or the reason it is refused.
+///
+/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses
+/// the path's own file name.
+pub fn openable(path: &str, label: &str) -> Result {
+ let p = Path::new(path);
+ let name = match label.trim() {
+ "" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()),
+ l => l.to_string(),
+ };
+ if !is_media(p) {
+ return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application"));
+ }
+ match std::fs::metadata(p) {
+ Ok(m) if m.is_file() => {}
+ Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")),
+ Err(e) => return Err(format!("the original is missing: {path} ({e})")),
+ }
+ // a link may name a photo and point at a program; what runs is what it points at
+ if let Ok(target) = p.canonicalize()
+ && !is_media(&target)
+ {
+ return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display()));
+ }
+ let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?;
+ Ok(absolute.to_string_lossy().into_owned())
+}
+
+/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included).
+pub fn is_media(path: &Path) -> bool {
+ crate::import::is_supported(path)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn write(dir: &Path, name: &str) -> String {
+ let p = dir.join(name);
+ std::fs::write(&p, b"x").unwrap();
+ p.to_string_lossy().into_owned()
+ }
+
+ #[test]
+ fn only_file_types_lightcraft_reads_are_handed_over() {
+ let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] {
+ let path = write(&dir, good);
+ assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads");
+ }
+ for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] {
+ let path = write(&dir, bad);
+ let e = openable(&path, "").unwrap_err();
+ assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}");
+ }
+ // a bundle (macOS `.app`) and any other folder are not files
+ let bundle = dir.join("Evil.app");
+ std::fs::create_dir_all(&bundle).unwrap();
+ let e = openable(&bundle.to_string_lossy(), "").unwrap_err();
+ assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}");
+ let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err();
+ assert!(e.contains("missing"), "{e}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// A relative path is made absolute: the opener runs with its own working directory.
+ #[test]
+ fn the_path_handed_over_is_absolute() {
+ let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = write(&dir, "a.jpg");
+ let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg");
+ let out = openable(&dotted.to_string_lossy(), "").unwrap();
+ assert!(Path::new(&out).is_absolute(), "{out}");
+ assert!(out.ends_with("a.jpg"), "{out}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// A link named like a photo that points at a program is refused: what the opener runs is
+ /// the target, not the name.
+ #[cfg(unix)]
+ #[test]
+ fn a_link_to_a_program_is_refused() {
+ let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let target = write(&dir, "payload.command");
+ let link = dir.join("holiday.jpg");
+ std::os::unix::fs::symlink(&target, &link).unwrap();
+ let e = openable(&link.to_string_lossy(), "").unwrap_err();
+ assert!(e.contains("points at") && e.contains("payload.command"), "{e}");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs
index 91e5db69e2..f4e50703bf 100644
--- a/crates/engine/src/print/compose.rs
+++ b/crates/engine/src/print/compose.rs
@@ -1,5 +1,6 @@
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
-//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s.
+//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop
+//! marks, page number, page info) and returns [`PageRaster`]s.
//!
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
@@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0;
pub const MAX_STROKE_PT: f64 = 36.0;
/// Renders kept for cells that print the same photo at the same size.
const RENDER_CACHE: usize = 4;
+/// A crop mark's length and its gap from the photo's corner, inches.
+pub const CROP_MARK_IN: f64 = 0.25;
+pub const CROP_MARK_GAP_IN: f64 = 0.0625;
+/// A crop mark's weight, points.
+pub const CROP_MARK_PT: f64 = 0.5;
+/// Size of the page number and page info text, points.
+pub const PAGE_TEXT_PT: f64 = 9.0;
/// Lightroom's Image Settings panel.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -85,11 +93,18 @@ impl ImageSettings {
pub struct PageStyle {
/// Page background colour, sRGB.
pub background: [u8; 3],
+ /// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them):
+ /// the page number at the bottom right of each page.
+ pub page_numbers: bool,
+ /// The print settings (sharpening, profile, resolution) at the bottom left of each page.
+ pub page_info: bool,
+ /// Marks at the corners of each photo to cut along.
+ pub crop_marks: bool,
}
impl Default for PageStyle {
fn default() -> Self {
- Self { background: [255; 3] }
+ Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false }
}
}
@@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
}
+/// Whether a layout style prints the Page Options (page numbers, page info, crop marks):
+/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead.
+pub fn has_page_options(style: Style) -> bool {
+ !style.is_package()
+}
+
+/// The colour of crop marks and page text on `background`: black, or white on a dark page.
+pub fn mark_ink(background: [u8; 3]) -> [u8; 3] {
+ let [r, g, b] = background.map(f64::from);
+ if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] }
+}
+
+/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer
+/// where the resolution is; this print goes to a PDF).
+pub fn page_info_text(settings: &PrintSettings) -> String {
+ use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
+ let c = &settings.job.color;
+ let media = match c.media {
+ PrintMedia::Matte => "Matte",
+ PrintMedia::Glossy => "Glossy",
+ };
+ let sharpen = match c.sharpen {
+ PrintSharpen::Off => "Off".to_string(),
+ PrintSharpen::Low => format!("Low ({media})"),
+ PrintSharpen::Standard => format!("Standard ({media})"),
+ PrintSharpen::High => format!("High ({media})"),
+ };
+ let profile = match &c.profile {
+ PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(),
+ PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(),
+ PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()),
+ };
+ format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi())
+}
+
// ---- placement ------------------------------------------------------------------------------
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
@@ -355,8 +405,8 @@ impl CellStyle {
}
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
-/// asked), then the inner stroke on top of it.
-pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> {
+/// asked), then the inner stroke on top of it. Returns where the photo landed.
+pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result {
// The border may not eat the cell: at least one pixel of photo area stays.
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
let (ax, ay) = (cell.x + border, cell.y + border);
@@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
}
- Ok(())
+ Ok(PxRect { x: dx, y: dy, w, h })
+}
+
+/// What the Page Options draw on every page, in page pixels.
+struct Marks {
+ /// Crop mark length, gap from the corner and weight.
+ crop: Option<(usize, usize, usize)>,
+ numbers: bool,
+ info: Option,
+ ink: [u16; 3],
+ text_px: f32,
+ /// The printable area: the text sits in the margin below it, aligned with its sides.
+ area: Option,
+}
+
+impl Marks {
+ fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self {
+ let on = has_page_options(settings.layout.style);
+ let page = &settings.page;
+ let px = |inches: f64| (inches * ppi).round() as usize;
+ Marks {
+ crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))),
+ numbers: on && page.page_numbers,
+ info: (on && page.page_info).then(|| page_info_text(settings)),
+ ink: rec2020_from_srgb8(mark_ink(page.background)),
+ text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32,
+ area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)),
+ }
+ }
+
+ /// Draws the marks of page `number` (one-based) around `photos`, the photos on it.
+ fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) {
+ if let Some((len, gap, weight)) = self.crop {
+ for r in photos {
+ crop_marks(page, r, len, gap, weight, photos, self.ink);
+ }
+ }
+ let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h });
+ let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32);
+ // centred in the bottom margin, and on the paper when the margin is thinner than the text
+ let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0));
+ if self.numbers
+ && let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS)
+ {
+ let x = ((area.x + area.w) as f32 - t.width).max(0.0);
+ draw_text(page, &t, x, top(t.height), self.ink);
+ }
+ if let Some(info) = &self.info
+ && let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS)
+ {
+ draw_text(page, &t, area.x as f32, top(t.height), self.ink);
+ }
+ }
+}
+
+/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts
+/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo.
+fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) {
+ let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64);
+ let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64);
+ let segments = [
+ (x0 - g - l, y0, l, t),
+ (x1 + g, y0, l, t),
+ (x0 - g - l, y1 - t, l, t),
+ (x1 + g, y1 - t, l, t),
+ (x0, y0 - g - l, t, l),
+ (x0, y1 + g, t, l),
+ (x1 - t, y0 - g - l, t, l),
+ (x1 - t, y1 + g, t, l),
+ ];
+ let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h);
+ for (sx, sy, sw, sh) in segments {
+ for y in sy.max(0)..(sy + sh).min(page.h as i64) {
+ for x in sx.max(0)..(sx + sw).min(page.w as i64) {
+ let (x, y) = (x as usize, y as usize);
+ if !on_photo(x, y) {
+ page.fill_rect(x, y, 1, 1, ink);
+ }
+ }
+ }
+ }
+}
+
+/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`).
+fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) {
+ let (w, h) = (page.w, page.h);
+ text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| {
+ let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return };
+ if gx >= w || gy >= h {
+ return;
+ }
+ let i = (gy * w + gx) * 3;
+ if let Some(px) = page.rgb.get_mut(i..i + 3) {
+ let k = cov.clamp(0.0, 1.0);
+ for (p, c) in px.iter_mut().zip(ink) {
+ *p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16;
+ }
+ }
+ });
}
// ---- the job ---------------------------------------------------------------------------------
@@ -402,6 +550,7 @@ pub struct Prepared {
ppi: f32,
background: [u16; 3],
style: CellStyle,
+ marks: Marks,
/// Print sharpening, applied to each photo once it's rendered at its print size.
color: crate::print_color::PrintColorSettings,
}
@@ -430,6 +579,7 @@ impl Prepared {
let mut out = Vec::with_capacity(self.pages.len());
for (index, &(w, h)) in self.pages.iter().enumerate() {
let mut page = Image16::filled(w, h, self.background)?;
+ let mut placed = Vec::new();
while let Some(item) = items.next_if(|i| i.page == index) {
if !progress(done, &item.name) {
return Err("Print cancelled".into());
@@ -448,8 +598,9 @@ impl Prepared {
}
};
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
- draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?;
+ placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?);
}
+ self.marks.draw(&mut page, &placed, index + 1);
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
}
if !progress(done, "") {
@@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI
ppi: ppi as f32,
background: rec2020_from_srgb8(settings.page.background),
style,
+ marks: Marks::of(&settings, ppi, pw, ph),
color: settings.job.color.clone(),
})
}
@@ -825,6 +977,94 @@ mod tests {
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
}
+ /// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the
+ /// margins are bare background unless something draws there).
+ fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec {
+ let ids = first_ids(s, 2);
+ let mut set = settings(style);
+ if style.is_package() {
+ set.layout.add_cell(3.0, 5.0).unwrap();
+ set.layout.new_page().unwrap();
+ let id = set.layout.add_cell(3.0, 5.0).unwrap();
+ set.layout.set_cell_page(id, 1).unwrap();
+ }
+ set.image.zoom_to_fill = true;
+ set.page = page;
+ prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap()
+ }
+
+ fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] {
+ let i = (y * p.width as usize + x) * 3;
+ [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
+ }
+
+ /// The pixels of `x0..x1` x `y0..y1` that are not the white background.
+ fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
+ (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count()
+ }
+
+ #[test]
+ fn crop_marks_sit_outside_each_photo_corner() {
+ let mut s = Session::with_demo();
+ let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default());
+ assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked");
+ let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() });
+ // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner,
+ // along each edge of the photo.
+ for p in &pages {
+ for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] {
+ assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})");
+ }
+ for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] {
+ assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})");
+ }
+ }
+ // a dark page gets white marks
+ let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() });
+ assert_eq!(rgb(&dark[0], 30, 50), WHITE);
+ }
+
+ #[test]
+ fn page_numbers_and_info_print_in_the_bottom_margin() {
+ let mut s = Session::with_demo();
+ let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() });
+ // bottom right: the number, different on each page; nothing at the bottom left
+ let (right, left) = ((200, 400), (0, 200));
+ for p in &numbered {
+ assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number");
+ assert_eq!(inked(p, left.0, left.1, 550, 600), 0);
+ assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top");
+ }
+ let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::>();
+ assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently");
+ let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() });
+ for p in &info {
+ assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line");
+ }
+ // the packages have no Page Options in Lightroom: nothing is drawn
+ let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() };
+ let package = page_options_pages(&mut s, Style::PicturePackage, all);
+ assert_eq!(package.len(), 2);
+ for p in &package {
+ assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0);
+ }
+ }
+
+ #[test]
+ fn page_info_names_the_sharpening_profile_and_resolution() {
+ use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
+ let mut set = PrintSettings::default();
+ let line = page_info_text(&set);
+ assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi");
+ set.job.color.sharpen = PrintSharpen::Off;
+ set.job.color.media = PrintMedia::Matte;
+ set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() };
+ set.job.ppi = 300.0;
+ assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi");
+ set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb };
+ assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)"));
+ }
+
#[test]
fn caps_and_hostile_settings_are_refused() {
let mut s = Session::with_demo();
diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs
index 16003cd61c..f0d7257936 100644
--- a/crates/engine/src/rename.rs
+++ b/crates/engine/src/rename.rs
@@ -916,7 +916,8 @@ fn plan_rename_core(
) -> Vec {
let mut taken: HashSet = HashSet::new(); // lower-case target paths/names claimed in this batch
let mut out = Vec::new();
- for (seq, p) in (start..).zip(photos) {
+ for (i, p) in photos.iter().enumerate() {
+ let seq = start.saturating_add(i);
let want = expand(template, p, seq);
let (stem, ext) = split_ext(&want);
let (stem, ext) = (stem.to_string(), ext.to_string());
diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs
index ea06d69a75..cdee1e8942 100644
--- a/crates/engine/src/sidecar.rs
+++ b/crates/engine/src/sidecar.rs
@@ -4,6 +4,8 @@
//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
+//! A sidecar is written only beside an original that is an existing file at an absolute path;
+//! otherwise the save fails.
//!
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
@@ -632,6 +634,10 @@ impl Session {
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
}
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
+ // A relative path would be taken from the working directory.
+ if Path::new(orig).is_relative() || !Path::new(orig).is_file() {
+ return Err(EngineError::Other(format!("{}: the original {orig} is missing; no XMP sidecar is written", p.file_name)));
+ }
let naming = owners.naming(p, self.xmp.naming);
let preferred = sidecar_path(orig, naming);
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
@@ -786,6 +792,30 @@ impl Session {
#[cfg(test)]
mod tests {
+ #[test]
+ fn sidecars_are_written_only_beside_an_existing_original() {
+ let dir = std::env::temp_dir().join(format!("lightcraft-sidecar-local-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ std::fs::write(dir.join("IMG_1.jpg"), b"original").unwrap();
+ let originals = [
+ (r"\\192.0.2.1\share\IMG_1.jpg".to_string(), false),
+ (dir.join("IMG_2.jpg").to_string_lossy().to_string(), false),
+ (dir.join("IMG_1.jpg").to_string_lossy().to_string(), true),
+ ];
+ for (original, written) in originals {
+ let _ = std::fs::remove_file(dir.join("IMG_1.xmp"));
+ let mut s = Session::new();
+ let p = Photo::new(PhotoId(1), Source::File { path: original.clone() }, "IMG_1.jpg", "JPEG", 4, 3, "2026-01-01T00:00:00");
+ s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
+ assert_eq!(s.save_sidecar(PhotoId(1)).is_ok(), written, "{original}");
+ assert_eq!(dir.join("IMG_1.xmp").is_file(), written, "{original}");
+ assert!(!dir.join("IMG_2.xmp").exists(), "{original}");
+ assert!(!Path::new(r"\\192.0.2.1\share\IMG_1.xmp").exists(), "{original}");
+ }
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+
/// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`)
/// gives the photo its keywords as paths.
#[test]
diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs
index 4097393353..a27f75d496 100644
--- a/crates/engine/src/slide_overlay.rs
+++ b/crates/engine/src/slide_overlay.rs
@@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma:
let my0 = (y - pad).floor().max(-pad);
let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad);
let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad);
- if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() {
+ if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) {
return;
}
let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize);
diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs
index 8f615bf0cb..dbee505af0 100644
--- a/crates/engine/src/slideshow.rs
+++ b/crates/engine/src/slideshow.rs
@@ -115,7 +115,7 @@ fn color(v: &Value) -> std::result::Result