From adf1a5e423bb3207169a781970a7be803b0f09e0 Mon Sep 17 00:00:00 2001 From: nevicia2010-hub <259836477+nevicia2010-hub@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:02:41 +0200 Subject: [PATCH] EXPORT-AUDIT: fix graphic watermarks and HDR JPEG size limits --- crates/engine/src/export.rs | 155 +++++++++++++++++++++++++++++------- docs/parity-checklist.md | 10 ++- 2 files changed, 132 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index e53dfcbc4..fcf4fe94d 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -553,7 +553,8 @@ fn watermark_logo(path: &str) -> Option> { return Some(img.clone()); } let bytes = std::fs::read(path).ok()?; - let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8()); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).ok()?; + let img = Arc::new(decoded.to_srgb8().into_oriented(lightcraft_geom::Orientation::from_exif(decoded.orientation))); map.insert(path.to_string(), (modified, img.clone())); Some(img) } @@ -568,8 +569,14 @@ fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl if width == 0 || height == 0 || logo.width == 0 || logo.height == 0 { return; } - let lw = ((wm.image_width.clamp(0.01, 1.0) * width as f32).round() as usize).clamp(1, width); - let lh = ((lw as f32 * logo.height as f32 / logo.width as f32).round() as usize).clamp(1, height); + let turns = wm.quarter_turns(); + // Fit the turned box with one scale: clamping height alone distorts tall graphics. + let (max_w, max_h) = if turns % 2 == 1 { (height, width) } else { (width, height) }; + let scale = (wm.image_width.clamp(0.01, 1.0) as f64 * width as f64 / logo.width as f64) + .min(max_w as f64 / logo.width as f64) + .min(max_h as f64 / logo.height as f64); + let lw = (logo.width as f64 * scale).round().max(1.0) as usize; + let lh = (logo.height as f64 * scale).round().max(1.0) as usize; let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(logo.width, logo.height, |x, y| { let p = logo.get(x, y); let a = p[3] as f32 / 255.0; @@ -578,7 +585,6 @@ fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl let scaled = lightcraft_raster::resample::resize(&pre, lw, lh, lightcraft_raster::resample::Filter::Mitchell); let short = width.min(height) as f32; let (ix, iy) = (wm.inset_x.clamp(0.0, 0.4) * short, wm.inset_y.clamp(0.0, 0.4) * short); - let turns = wm.quarter_turns(); let (bw, bh) = if turns % 2 == 1 { (lh, lw) } else { (lw, lh) }; let (w, h, lwf, lhf) = (width as f32, height as f32, bw as f32, bh as f32); use Anchor::*; @@ -694,8 +700,8 @@ pub struct ExportOptions { /// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`]. pub bit_depth: Option, /// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR - /// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb` - /// not applied), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers + /// rendition plus a gain map, so the file looks right everywhere; `limit_kb` includes both + /// images and their metadata), AVIF as 10-bit Rec. 2020 PQ, PNG as 16-bit Rec. 2020 PQ (`cICP`; viewers /// without cICP support show it as sRGB, washed out), JPEG XL as 16-bit Rec. 2020 PQ, 32-bit float /// TIFF with the highlights above SDR white kept. Other formats, and photos without an HDR /// edit, are SDR. @@ -1348,29 +1354,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada ExportFormat::Jpeg => { // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; - let jpeg = |q: u8| encode::encode_jpeg(&e, q, sub, &meta); - match o.limit_kb { - Some(kb) => { - let limit = kb as usize * 1024; - // Binary search for the highest quality that fits. - let (mut lo, mut hi) = (1u8, o.quality); - let mut best = None; - while lo <= hi { - let mid = lo + (hi - lo) / 2; - let b = jpeg(mid).map_err(|e| e.to_string())?; - if b.len() <= limit { - best = Some(b); - lo = mid + 1; - } else if mid == 1 { - break; - } else { - hi = mid - 1; - } - } - return best.ok_or_else(|| format!("cannot fit within {kb} KB")); - } - None => jpeg(o.quality), - } + return encode_jpeg_with_limit(o, |q| encode::encode_jpeg(&e, q, sub, &meta)); } ExportFormat::Png => encode::encode_png(&e, &meta), ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), @@ -1384,6 +1368,27 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada r.map_err(|e| e.to_string()) } +/// Apply the same file-size policy to ordinary JPEGs and complete HDR gain map JPEGs. +fn encode_jpeg_with_limit(o: &ExportOptions, jpeg: impl Fn(u8) -> lightcraft_codecs::Result>) -> Result, String> { + let Some(kb) = o.limit_kb else { return jpeg(o.quality).map_err(|e| e.to_string()) }; + let limit = u64::from(kb) * 1024; + let (mut lo, mut hi) = (1u8, o.quality.min(100)); + let mut best = None; + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let b = jpeg(mid).map_err(|e| e.to_string())?; + if b.len() as u64 <= limit { + best = Some(b); + lo = mid + 1; + } else if mid == 1 { + break; + } else { + hi = mid - 1; + } + } + best.ok_or_else(|| format!("cannot fit within {kb} KB")) +} + /// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { let mut img = img.clone(); @@ -1431,7 +1436,7 @@ pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, met let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; - gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string()) + encode_jpeg_with_limit(o, |q| gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, q, sub, &em)) } /// The codecs' name of an output space (for its ICC profile). @@ -2254,6 +2259,96 @@ mod tests { assert!(b.len() <= kb as usize * 1024); } + #[test] + fn hdr_jpeg_size_limit_respected() { + let img = Rgba8::from_fn(256, 192, |x, y| { + let n = (x as u32).wrapping_mul(73856093) ^ (y as u32).wrapping_mul(19349663); + [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255] + }); + let hdr = DeepImage { + width: img.width, + height: img.height, + space: OutputSpace::Srgb, + samples: DeepSamples::F32( + img.data.iter().flat_map(|p| p[..3].iter().map(|v| lightcraft_color::transfer::decode_srgb8(*v) * 4.0)).collect(), + ), + }; + let opts = ExportOptions { hdr: true, quality: 100, limit_kb: Some(u32::MAX), ..Default::default() }; + let full = encode_gain_map_jpeg(&img, &hdr, &opts, None).unwrap(); + let kb = (full.len() / 2048) as u32; + let limited = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(kb), ..opts.clone() }, None).unwrap(); + assert!(limited.len() <= kb as usize * 1024, "{} bytes exceeds {kb} KB", limited.len()); + let decoded = lightcraft_codecs::gainmap::read_jpeg(&limited).unwrap(); + assert!(decoded.meta.alternate_headroom > 1.0, "the gain map must survive size limiting"); + let impossible = encode_gain_map_jpeg(&img, &hdr, &ExportOptions { limit_kb: Some(1), ..opts }, None); + assert!(impossible.is_err_and(|e| e.contains("cannot fit within 1 KB"))); + } + + #[test] + fn graphic_watermark_honours_exif_orientation() { + let dir = std::env::temp_dir().join(format!("lc-wm-orientation-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let logo = Rgba8::from_fn(40, 20, |x, _| if x < 20 { [255, 0, 0, 255] } else { [0, 0, 255, 255] }); + for orientation in [2, 6] { + let meta = Metadata { orientation: Some(lightcraft_geom::Orientation::from_exif(orientation)), ..Default::default() }; + let exif = lightcraft_meta::write_exif(&meta); + let bytes = lightcraft_codecs::encode_jpeg( + &EncodeImage::rgba8(&logo), + 100, + ChromaSubsampling::S444, + &EncodeMeta { exif: Some(&exif), ..Default::default() }, + ) + .unwrap(); + let path = dir.join(format!("oriented-{orientation}.jpg")); + std::fs::write(&path, bytes).unwrap(); + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: if orientation == 6 { 0.25 } else { 0.5 }, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + ..Default::default() + }; + let mut img = Rgba8::from_fn(80, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + if orientation == 6 { + assert!(img.get(70, 45)[0] > 240 && img.get(70, 75)[2] > 240, "EXIF 6: red above blue"); + } else { + assert!(img.get(45, 70)[2] > 240 && img.get(75, 70)[0] > 240, "EXIF 2: blue left of red"); + } + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn graphic_watermark_keeps_aspect_when_fitted_and_rotated() { + let dir = std::env::temp_dir().join(format!("lc-wm-aspect-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("tall.png"); + let logo = Rgba8::from_fn(40, 160, |_, _| [255, 0, 0, 255]); + std::fs::write(&path, lightcraft_codecs::encode_png(&EncodeImage::rgba8(&logo), &EncodeMeta::default()).unwrap()).unwrap(); + for (rotation, expected) in [(0, (20, 80)), (90, (120, 30)), (180, (20, 80)), (270, (120, 30))] { + let wm = Watermark { + image: path.to_string_lossy().into(), + image_width: 0.5, + opacity: 1.0, + inset_x: 0.0, + inset_y: 0.0, + rotation, + ..Default::default() + }; + let mut img = Rgba8::from_fn(120, 80, |_, _| [0, 0, 0, 255]); + draw_watermark(&mut img, &wm); + let lit: Vec<_> = img.data.iter().enumerate().filter(|(_, p)| p[0] > 200).map(|(i, _)| (i % 120, i / 120)).collect(); + let bounds = ( + lit.iter().map(|p| p.0).max().unwrap() - lit.iter().map(|p| p.0).min().unwrap() + 1, + lit.iter().map(|p| p.1).max().unwrap() - lit.iter().map(|p| p.1).min().unwrap() + 1, + ); + assert_eq!(bounds, expected, "{rotation} degrees: fitting must preserve the logo's aspect ratio"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn sharpen_increases_edge_contrast() { let mut img = Rgba8::new(8, 8); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..711d6d930 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (export regression coverage: graphic watermark EXIF orientation and aspect-preserving fit; HDR JPEG file-size limits; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -387,14 +387,14 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | | LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags | -| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | +| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | Ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`; the complete file, including gain map and metadata, must fit or export returns an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | | LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | -| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | +| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (EXIF orientation applied before sizing; oversized graphics fit the rotated image bounds without stretching, tested by `graphic_watermark_honours_exif_orientation` and `graphic_watermark_keeps_aspect_when_fitted_and_rotated`; `image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | @@ -814,3 +814,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | | 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | + +### 2026-10-13 — export regression fixes (minor) + +Graphic watermarks honour EXIF rotation/mirroring and keep their aspect ratio when fitted to the output. HDR JPEG `limitKb` now includes the gain map and metadata. No feature-count or readiness estimate changes.