Merge pull request #10387 from davidmann4/fix/flaky-split-route

CMYK: adjustment layers in the special eight keep the Fill rule on the ink path (#10386)
This commit is contained in:
David Mann authored and GitHub committed 2026-11-03 21:39:21 +01:00
commit 81bd97376f
2 files changed
+65 -8

No files matched your search

+13 -5
View File
@@ -190,10 +190,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) {
@@ -401,9 +407,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);
@@ -422,7 +430,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);
}
}