CMYK: adjustment layers in the special eight keep the Fill rule on the ink path (#10386)

#9512 made the RGB compositor and the GPU blend an adjustment layer's Fill
inside the special eight (Fill is part of the blend, Opacity alone mixes it
in). The ink compositor's mix_adjusted still took Opacity x Fill as coverage,
so a Linear Burn Levels layer at Fill 71% exported Y 0.957 where the display
shows 0.988, and the_split_route_agrees_with_the_exact_path failed whenever
the proptest drew such a stack.

mix_adjusted now does what composite.rs does, and the test reference model
does the same. Regression test: the shrunk case plus every special-eight mode
at Fill 40% with a mask, against the reference and the split route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David MannandClaude Opus 5.5 committed 2026-10-30 23:03:31 +01:00
1 parent 952c328b3d
commit 60efbeeb08
2 files changed
+65 -8

No files matched your search

+13 -5
View File
@@ -189,10 +189,16 @@ pub fn separable(mode: BlendMode) -> bool {
/// channel's brightness (1 − ink), as CMYK channels are blended, so Multiply adds ink and Screen
/// takes it away.
pub fn blend_ink(mode: BlendMode, cb: [f32; 4], cs: [f32; 4]) -> [f32; 4] {
blend_ink_fill(mode, cb, cs, 1.0)
}
/// [`blend_ink`] at `fill`: below 100% the special eight change the blend itself
/// (`psblend::blend_rgb_fill`, on each ink's brightness); other modes ignore `fill`.
fn blend_ink_fill(mode: BlendMode, cb: [f32; 4], cs: [f32; 4], fill: f32) -> [f32; 4] {
let b = |i: usize| 1.0 - cb[i];
let s = |i: usize| 1.0 - cs[i];
let cmy = super::psblend::blend_rgb(mode, [b(0), b(1), b(2)], [s(0), s(1), s(2)]);
let k = super::psblend::blend_rgb(mode, [b(3); 3], [s(3); 3]);
let cmy = super::psblend::blend_rgb_fill(mode, [b(0), b(1), b(2)], [s(0), s(1), s(2)], fill);
let k = super::psblend::blend_rgb_fill(mode, [b(3); 3], [s(3); 3], fill);
// A result that is one of the inputs (Normal, Darken, Lighten, ...) is that ink exactly.
let ink = |r: f32, i: usize| {
if r == s(i) {
@@ -399,9 +405,11 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) {
}
/// Mixes an adjustment's result back into `base` in the layer's blend mode, by its opacity and
/// mask; coverage is unchanged (the compositor's adjustment mix).
/// mask; coverage is unchanged (the compositor's adjustment mix). Fill below 100% in one of the
/// special eight is part of the blend and leaves the mix to Opacity, as on the RGB compositor.
fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) {
let opacity = l.opacity * l.fill_opacity;
let special = l.fill_opacity < 1.0 && super::psblend::fill_is_special(l.blend);
let (opacity, fill) = if special { (l.opacity, l.fill_opacity) } else { (l.opacity * l.fill_opacity, 1.0) };
let rect = base.rect;
let w = (rect.width() as usize).max(1);
let mv = mask_vals(l, rect, cx);
@@ -420,7 +428,7 @@ fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ct
}
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_fill(l.blend, [p[0], p[1], p[2], p[3]], [a[0], a[1], a[2], a[3]], fill);
for (v, b) in p.iter_mut().zip(bl) {
*v = if k >= 1.0 { b } else { *v + (b - *v) * k };
}
+52 -3
View File
@@ -214,20 +214,24 @@ fn ref_adjust(adj: &Adjustment, p: Cmyka) -> Cmyka {
}
/// An adjustment layer `l` (adjusting `p`, then `clipped` onto that) mixed into `p` in its blend
/// mode by its opacity and mask; coverage stays.
/// mode by its opacity and mask; coverage stays. In the special eight below 100% Fill, Fill is
/// part of the blend (`psblend::blend_rgb_fill`, on brightness) and Opacity alone mixes it in.
fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32, y: i32) -> Cmyka {
let mut a = ref_adjust(adj, p);
if p[4] > 0.0 {
a = ref_clip(clipped, a, x, y);
}
let k = l.opacity * l.fill_opacity * ref_mask(l, x, y);
let special = l.fill_opacity < 1.0 && crate::psblend::fill_is_special(l.blend);
let (opacity, fill) = if special { (l.opacity, l.fill_opacity) } else { (l.opacity * l.fill_opacity, 1.0) };
let k = 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;
for c in 0..4 {
o[c] = p[c] + (ref_blend(l.blend, p[c], a[c]) - p[c]) * k;
let m = 1.0 - crate::psblend::blend_rgb_fill(l.blend, [1.0 - p[c]; 3], [1.0 - a[c]; 3], fill)[0];
o[c] = p[c] + (m - p[c]) * k;
}
o
}
@@ -1858,3 +1862,48 @@ fn every_cmyk_route_rounds_after_an_adjustment_layer() {
assert_eq!((display, export), (105.0 / 255.0, 105.0 / 255.0), "opacity {opacity}");
}
}
/// An adjustment layer in one of the special eight below 100% Fill: Fill is part of the blend and
/// Opacity alone mixes it in, on the ink path as on the RGB compositor (#9512; Linear Burn at
/// Fill 71% put Y 0.957 where the split route gives 0.988).
#[test]
fn adjustment_layers_keep_the_special_fill_rule() {
let levels = AdjSpec::Levels { channel: 3, levels: [0.125, 0.8, 1.1, 0.14, 0.78] };
let s = Spec {
w: 3,
h: 1,
layers: vec![
raster_spec(BlendMode::Normal, (Rect::new(0, 0, 3, 1), [0.0, 0.0, 0.88, 1.0, 1.0]), 1.0, false),
LayerSpec::Pixels {
kind: Kind::Adjust(levels.clone()),
blend: BlendMode::LinearBurn,
fills: vec![],
opacity: 0.9,
fill: 0.71,
clipped: false,
visible: true,
mask: None,
},
],
};
assert_split_matches_exact(&s);
for mode in SEPARABLE.iter().copied().filter(|m| crate::psblend::fill_is_special(*m)) {
let mut d = doc(4, 2);
d.layers.push(paper(4, 2, [0.2, 0.5, 0.1, 0.1]));
d.layers.push(layer(&[(Rect::new(0, 0, 3, 2), [0.6, 0.1, 0.4, 0.2, 0.8])]));
let mut a = build(&LayerSpec::Pixels {
kind: Kind::Adjust(levels.clone()),
blend: mode,
fills: vec![],
opacity: 0.9,
fill: 0.4,
clipped: false,
visible: true,
mask: None,
});
a.mask = Some(mask(&[(Rect::new(2, 0, 4, 2), 0.5)], 1.0, 1.0, true));
d.layers.push(a);
assert_matches_reference(&d);
assert_routes_agree(&d);
}
}