Merge PR #5771: Lab: adjustment results are whole samples, as the reference app stores them (lab8 curves passes)

crates/io/tests/corpus.rs: kept main's pass_floor 188 (TAKE-FIX: drop the PR's 186 hunk)
docs/file-format-parity.md: kept main's header and PSD/PSB rows; added the PR's change-log row without its floor figure
docs/gaps.md: kept main's header and evidence line; added the PR's change-log row without its floor figure
docs/scorecard.md: ours
This commit is contained in:
aiongg
2026-10-22 15:37:35 +00:00
4 changed files with 35 additions and 1 deletions
+7 -1
View File
@@ -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<Item<'a>>,
/// Steps per unit of the document's samples (`None` at 32 bits): adjustment results are
/// whole samples, as Photoshop stores them.
quantum: Option<f32>,
}
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;
}
}
}
}
+26
View File
@@ -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::<Vec<_>>();
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]
+1
View File
@@ -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) |
+1
View File
@@ -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 |