CMYK: Dissolve and post-adjustment rounding match the RGB compositor (#9033)

The CMYK proptests failed on main now and then (the_split_route_agrees_with_the_exact_path
after #8663). Three gaps, each found by the proptests at 5000 cases:

- cmyk.rs: a clipped Dissolve layer blended like Normal; it now shows each pixel fully or not
  at all by the compositor's noise, as the RGB compositor does since #3052.
- cmyk.rs: a Dissolve adjustment layer mixed by its opacity; opacity and mask are now the chance
  a pixel takes the adjustment, as on the RGB compositor (#3050).
- native.rs: the display/Flatten ink route didn't round to the document's levels after an
  adjustment layer, where the RGB compositor and cmyk.rs do; coverage in a pass-through group
  then differed by up to half a level.

The test reference model had the same two Dissolve gaps (ref_clip passed no position, so
Dissolve fell back to Normal), which is why random_documents_match_the_reference never caught
the first one. Three named regression tests cover the minimal cases.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Mann
2026-10-24 03:34:07 +02:00
co-authored by Claude Opus 5.5
parent a02f2ee410
commit 0b3cab2eba
4 changed files with 143 additions and 7 deletions
@@ -5,3 +5,5 @@
# It is recommended to check this file in to source control so that # It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases. # everyone who runs the test benefits from these saved cases.
cc a795914e4492bef1e662315563d48d54f09030c48e046df95329465c86dde4b4 # shrinks to s = Spec { w: 4, h: 6, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [1.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 1.0105422, 0.0, 0.7] }), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }] } cc a795914e4492bef1e662315563d48d54f09030c48e046df95329465c86dde4b4 # shrinks to s = Spec { w: 4, h: 6, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [1.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 1.0105422, 0.0, 0.7] }), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }] }
cc e14b02709a87770da2637c28678c4fa761b5628f402e0c6d60fe66f1aa4d4bfa # shrinks to s = Spec { w: 3, h: 2, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: false, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }, Group { children: [Pixels { kind: Adjust(Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] }), blend: Dissolve, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.8972109, fill: 1.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
cc 4eeba1f8db987228717b932b82d043d61e82fd897a33ea9e2ce68d6c61d7f491 # shrinks to s = Spec { w: 3, h: 5, layers: [Group { children: [Pixels { kind: Raster, blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 1.0])], opacity: 1.0, fill: 0.026626235, clipped: false, visible: true, mask: None }, Pixels { kind: Adjust(Invert), blend: Normal, fills: [(Rect { x0: 0, y0: 0, x1: 1, y1: 1 }, [0.0, 0.0, 0.0, 0.0, 0.0])], opacity: 0.0, fill: 0.0, clipped: false, visible: true, mask: None }], pass_through: true, blend: Normal, opacity: 1.0, fill: 1.0, mask: None }] }
+30 -6
View File
@@ -402,12 +402,24 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) {
/// mask; coverage is unchanged (the compositor's adjustment mix). /// mask; coverage is unchanged (the compositor's adjustment mix).
fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) { fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) {
let opacity = l.opacity * l.fill_opacity; let opacity = l.opacity * l.fill_opacity;
let mv = mask_vals(l, base.rect, cx); let rect = base.rect;
let w = (rect.width() as usize).max(1);
let mv = mask_vals(l, rect, cx);
for (i, (p, a)) in base.px.iter_mut().zip(&adjusted.px).enumerate() { for (i, (p, a)) in base.px.iter_mut().zip(&adjusted.px).enumerate() {
let k = opacity * mask_at(&mv, i); let k = opacity * mask_at(&mv, i);
if k <= 0.0 { if k <= 0.0 {
continue; continue;
} }
if l.blend == BlendMode::Dissolve {
// As on the RGB compositor (#3050): opacity and mask are the chance a pixel takes the
// adjustment fully, by the same noise.
let x = rect.x0.saturating_add((i % w) as i32);
let y = rect.y0.saturating_add((i / w) as i32);
if super::dissolve_noise(x, y) < k {
*p = [a[0], a[1], a[2], a[3], p[4]];
}
continue;
}
let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]); let bl = blend_ink(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]]);
for (v, b) in p.iter_mut().zip(bl) { for (v, b) in p.iter_mut().zip(bl) {
*v = if k >= 1.0 { b } else { *v + (b - *v) * k }; *v = if k >= 1.0 { b } else { *v + (b - *v) * k };
@@ -416,10 +428,10 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct
} }
/// Rounds to the document's levels after an adjustment layer, as the compositor does (on the /// Rounds to the document's levels after an adjustment layer, as the compositor does (on the
/// channel brightness the document stores, and coverage). /// channel brightness the document stores, and coverage). Also used by [`super::native`].
fn quantize_inks(buf: &mut CmykBuffer, q: f32) { pub(crate) fn quantize_inks(px: &mut [Cmyka], q: f32) {
let round = |v: f32| (v * q + 0.5).floor() / q; let round = |v: f32| (v * q + 0.5).floor() / q;
for p in &mut buf.px { for p in px {
for v in p.iter_mut().take(4) { for v in p.iter_mut().take(4) {
// Whole levels of ink: exactly the stored values, rounding ties as on brightness. // Whole levels of ink: exactly the stored values, rounding ties as on brightness.
*v = (q - ((1.0 - *v) * q + 0.5).floor()) / q; *v = (q - ((1.0 - *v) * q + 0.5).floor()) / q;
@@ -441,11 +453,23 @@ fn composite_clipped(clipped: &[Layer], base: &mut CmykBuffer, cx: &Ctx) {
let content = layer_content(c, base.rect, cx); let content = layer_content(c, base.rect, cx);
let opacity = c.opacity * c.fill_opacity; let opacity = c.opacity * c.fill_opacity;
let gamma = super::text_gamma(c); let gamma = super::text_gamma(c);
for (p, s) in base.px.iter_mut().zip(&content.px) { let rect = base.rect;
let w = (rect.width() as usize).max(1);
for (i, (p, s)) in base.px.iter_mut().zip(&content.px).enumerate() {
let alpha = p[4]; let alpha = p[4];
if alpha <= 0.0 { if alpha <= 0.0 {
continue; continue;
} }
if c.blend == BlendMode::Dissolve {
// As on the RGB compositor (#3052): the base keeps its alpha and each pixel takes
// the clipped layer fully or not at all, by the same noise.
let x = rect.x0.saturating_add((i % w) as i32);
let y = rect.y0.saturating_add((i / w) as i32);
if super::dissolve_noise(x, y) < s[4] * opacity {
*p = [s[0], s[1], s[2], s[3], alpha];
}
continue;
}
let r = composite_ink(c.blend, [p[0], p[1], p[2], p[3], 1.0], *s, opacity, gamma); let r = composite_ink(c.blend, [p[0], p[1], p[2], p[3], 1.0], *s, opacity, gamma);
*p = [r[0], r[1], r[2], r[3], alpha]; *p = [r[0], r[1], r[2], r[3], alpha];
} }
@@ -477,7 +501,7 @@ fn composite_stack(layers: &[Layer], backdrop: &mut CmykBuffer, cx: &Ctx) {
if base.visible { if base.visible {
composite_layer(base, clipped, backdrop, cx); composite_layer(base, clipped, backdrop, cx);
if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, super::adjustment_quantum(cx.depth)) { if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, super::adjustment_quantum(cx.depth)) {
quantize_inks(backdrop, q); quantize_inks(&mut backdrop.px, q);
} }
} }
} }
+102 -1
View File
@@ -219,6 +219,10 @@ fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32
a = ref_clip(clipped, a, x, y); a = ref_clip(clipped, a, x, y);
} }
let k = l.opacity * l.fill_opacity * ref_mask(l, x, y); let k = l.opacity * l.fill_opacity * ref_mask(l, x, y);
if l.blend == BlendMode::Dissolve {
// Opacity and mask are the chance the pixel takes the adjustment fully.
return if k > 0.0 && crate::dissolve_noise(x, y) < k { [a[0], a[1], a[2], a[3], p[4]] } else { p };
}
let mut o = p; let mut o = p;
for c in 0..4 { for c in 0..4 {
o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k; o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k;
@@ -233,7 +237,7 @@ fn ref_clip(clipped: &[Layer], mut content: Cmyka, x: i32, y: i32) -> Cmyka {
content = ref_adjusted(c, adj, &[], content, x, y); content = ref_adjusted(c, adj, &[], content, x, y);
} else if content[4] > 0.0 { } else if content[4] > 0.0 {
let a = content[4]; let a = content[4];
let r = over_layer(c, [content[0], content[1], content[2], content[3], 1.0], ref_own(c, x, y), c.opacity * c.fill_opacity, None); let r = over_layer(c, [content[0], content[1], content[2], content[3], 1.0], ref_own(c, x, y), c.opacity * c.fill_opacity, Some((x, y)));
content = [r[0], r[1], r[2], r[3], a]; content = [r[0], r[1], r[2], r[3], a];
} }
} }
@@ -1697,3 +1701,100 @@ fn rgb_colours_become_ink_through_the_document_profile() {
assert!(!supports(&overlay) && split::supports(&overlay)); assert!(!supports(&overlay) && split::supports(&overlay));
check(&overlay, "colour overlay"); check(&overlay, "colour overlay");
} }
/// The split route and the exact path give the same inks for `s`, alpha included.
fn assert_split_matches_exact(s: &Spec) {
let mut d = doc(s.w as u32, s.h as u32);
d.layers = s.layers.iter().map(build).collect();
assert!(supports(&d) && split::supports(&d));
let via = split_render(&d);
let exact = render_cmyk(&d, via.rect).unwrap_or_else(|| panic!("supported"));
for (i, (g, w)) in via.px.iter().zip(&exact.px).enumerate() {
assert!((0..5).all(|c| (g[c] - w[c]).abs() <= 1e-5), "pixel {i}: {g:?} vs {w:?}");
}
}
fn raster_spec(blend: BlendMode, fill: (Rect, Cmyka), opacity: f32, clipped: bool) -> LayerSpec {
LayerSpec::Pixels { kind: Kind::Raster, blend, fills: vec![fill], opacity, fill: 1.0, clipped, visible: true, mask: None }
}
fn group_spec(children: Vec<LayerSpec>, pass_through: bool) -> LayerSpec {
LayerSpec::Group { children, pass_through, blend: BlendMode::Normal, opacity: 1.0, fill: 1.0, mask: None }
}
/// A clipped Dissolve layer shows each pixel fully or not at all on the exact path too, as on the
/// RGB compositor (#9033; it used to mix like Normal: K 0.81 where the noise hides the layer).
#[test]
fn a_clipped_dissolve_layer_dissolves_on_the_exact_path() {
let s = Spec {
w: 4,
h: 5,
layers: vec![group_spec(
vec![
raster_spec(BlendMode::Normal, (Rect::new(0, 0, 2, 1), [0.0, 0.0, 0.0, 1.0, 1.0]), 1.0, false),
raster_spec(BlendMode::Dissolve, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 0.189_737_02, true),
],
false,
)],
};
assert_split_matches_exact(&s);
}
/// A Dissolve adjustment layer takes each pixel fully or not at all on the exact path too (#9033;
/// it used to mix by opacity: cyan 0.267 where the noise picks the adjusted 0.298).
#[test]
fn a_dissolve_adjustment_dissolves_on_the_exact_path() {
let levels = AdjSpec::Levels { channel: 0, levels: [0.0, 0.6, 0.3, 0.0, 0.7] };
let adjust = LayerSpec::Pixels {
kind: Kind::Adjust(levels),
blend: BlendMode::Dissolve,
fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])],
opacity: 0.897_210_9,
fill: 1.0,
clipped: false,
visible: true,
mask: None,
};
let s = Spec {
w: 3,
h: 2,
layers: vec![
group_spec(vec![raster_spec(BlendMode::Normal, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 1.0, false)], false),
group_spec(vec![adjust], true),
],
};
assert_split_matches_exact(&s);
}
/// After an adjustment layer, the display route ([`crate::native`]) rounds to the document's
/// levels like the export path and the RGB compositor, at any opacity (#9033: in a pass-through
/// group its coverage stayed at 41.32% where the others store 105/255).
#[test]
fn every_cmyk_route_rounds_after_an_adjustment_layer() {
for opacity in [0.0, 0.5] {
let adjust = LayerSpec::Pixels {
kind: Kind::Adjust(AdjSpec::Invert),
blend: BlendMode::Normal,
fills: vec![(Rect::new(0, 0, 1, 1), [0.0; 5])],
opacity,
fill: 1.0,
clipped: false,
visible: true,
mask: None,
};
let s = Spec {
w: 8,
h: 5,
layers: vec![group_spec(vec![raster_spec(BlendMode::Normal, (Rect::new(0, 0, 1, 1), [0.0, 0.0, 0.0, 0.0, 1.0]), 1.0, false), adjust], true)],
};
let mut d = doc(s.w as u32, s.h as u32);
d.layers = s.layers.iter().map(build).collect();
if let LayerContent::Group(g) = &mut d.layers[0].content {
g.children[0].fill_opacity = 0.413_248_66;
}
let rect = Rect::new(0, 0, 1, 1);
let display = crate::render(&d, rect).px[0][3];
let export = render_cmyk(&d, rect).unwrap_or_else(|| panic!("supported")).px[0][4];
assert_eq!((display, export), (105.0 / 255.0, 105.0 / 255.0), "opacity {opacity}");
}
}
+9
View File
@@ -214,7 +214,13 @@ impl<'a> InkStack<'a> {
} }
} }
Item::Adjust(op, opacity, mask) => { Item::Adjust(op, opacity, mask) => {
// The result is rounded to the document's levels, as the compositor and the
// export path do after every adjustment layer, at any opacity (#9033).
let quantum = crate::adjustment_quantum(self.depth);
if *opacity <= 0.0 { if *opacity <= 0.0 {
if let Some(q) = quantum {
crate::cmyk::quantize_inks(acc, q);
}
continue; continue;
} }
masked(*mask, mv); masked(*mask, mv);
@@ -228,6 +234,9 @@ impl<'a> InkStack<'a> {
d[c] += (v - d[c]) * mix; d[c] += (v - d[c]) * mix;
} }
} }
if let Some(q) = quantum {
crate::cmyk::quantize_inks(acc, q);
}
} }
} }
} }