diff --git a/crates/compose/src/native_lab.rs b/crates/compose/src/native_lab.rs index 0db59499de..771240a46b 100644 --- a/crates/compose/src/native_lab.rs +++ b/crates/compose/src/native_lab.rs @@ -89,6 +89,9 @@ fn collect<'a>(layers: &'a [Layer], depth: SampleType, adjustments: bool, items: /// A Lab document's visible layers, prepared for compositing on their stored L, a and b. pub struct LabStack<'a> { items: Vec>, + /// Steps per unit of the document's samples (`None` at 32 bits): adjustment results are + /// whole samples, as Photoshop stores them. + quantum: Option, } impl<'a> LabStack<'a> { @@ -101,7 +104,7 @@ impl<'a> LabStack<'a> { } let mut items = Vec::new(); collect(&doc.layers, doc.depth, true, &mut items, 0)?; - Some(LabStack { items }) + Some(LabStack { items, quantum: crate::adjustment_quantum(doc.depth) }) } /// Whether the stack has an adjustment layer (the RGB compositor can't match it exactly). @@ -161,6 +164,9 @@ impl<'a> LabStack<'a> { op(&mut lab); for (c, v) in lab.iter().enumerate() { d[c] += (v - d[c]) * mix; + if let Some(q) = self.quantum { + d[c] = (d[c] * q + 0.5).floor() / q; + } } } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index af87a8b652..5bc5be6b16 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3217,6 +3217,32 @@ fn lab_adjustments_work_on_the_stored_values() { near3([p[0], p[1], p[2]], lab_display([l, byte(218), byte(38)]), 1e-3, "levels on L at half opacity"); } +// An adjustment's result in a Lab document is whole samples, as Photoshop stores it: Curves +// taking L 100 to 120 of 255 shows L 120 exactly (the photoshop corpus's lab8 curves.psd is exact +// so, where the unrounded result left saturated colours a level off on screen), at 8 bits; 16 bits +// round to 1/32768. +#[test] +fn lab_adjustment_results_are_whole_samples() { + let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::>(); + let curves = |l: &[(f32, f32)]| Adjustment::Curves { + master: Vec::new(), + per_channel: [pts(l), Vec::new(), Vec::new()], + space: ToneSpace::Lab, + black: Vec::new(), + pencil: false, + }; + // A straight line from (0, 0) through (100, 120.4): L 100 lands between two bytes. + let adj = curves(&[(0.0, 0.0), (100.0 / 255.0, 120.4 / 255.0), (1.0, 1.0)]); + for (depth, q) in [(SampleType::U8, 255.0f32), (SampleType::U16, 32768.0)] { + let doc = lab_stack([100.0 / 255.0, 0.5, 0.5], depth, vec![adj.clone()]); + let p = px(&doc, 1, 1); + let stack = native_lab::LabStack::new(&doc); + assert!(stack.is_some_and(|s| s.adjusts()), "{depth:?}: the native path"); + let l = photocraft_color::convert::srgb_to_lab([p[0], p[1], p[2]])[0] / 100.0; + assert!((l * q - (l * q).round()).abs() < 0.02, "{depth:?}: L {} is between two samples", l * q); + } +} + // Flattening a Lab document keeps its stored values, out-of-gamut colours included (the RGB // compositor clipped them on the way through display RGB). #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8cccdd1bea..91b4eb6d5a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Lab documents round each adjustment layer's result on the stored L, a, b to whole samples, as Photoshop stores them (the native Lab path): photoshop lab8 curves.psd passes | | 2026-10-15 | minor | PSD smart filters: Clouds, Difference Clouds, Fibers and Lens Flare open as editable filters with their seeds, colours and lens, and save like Photoshop (checked against a Photoshop 27 file); render filters on a smart object work within the object, so a flare sits where Photoshop puts it | | 2026-10-15 | minor | DDS textures save (#1327): uncompressed or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, optional box-filtered mip chain, legacy header; DDS Options in Save As and Save a Copy, `ddsCompression`/`ddsMipmaps` for scripts, the CLI and MCP | | 2026-10-16 | minor | PSD smart filters: Perspective Warp (`perspectiveWarpTransform`: `vertices`, `warpedVertices`, `quads` of vertex indices, document pixels) opens as `layer.smartObjects.perspectiveWarp`, one plane per quad, and re-renders warped; save writes the original item with only the geometry rewritten, or a new one in Photoshop's layout; inconsistent descriptors stay verbatim (FILE-215-2, #6268) | diff --git a/docs/gaps.md b/docs/gaps.md index 9b281d6786..2460a13391 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -290,6 +290,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | G1: Lab adjustment layers' results are whole samples (native Lab path); photoshop lab8 curves passes | | 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 2026-10-15 | minor | G2: a cancelled 24 MP Gaussian Blur job stops within 44 ms max (P25, budget 200 ms; was about 400 ms; local runs, baseline not re-run), the uncancelled job on a locked-transparency layer about 40% faster; #6205 |