diff --git a/crates/compose/src/cmyk.rs b/crates/compose/src/cmyk.rs index 96a6df0ceb..7b3a18f3ea 100644 --- a/crates/compose/src/cmyk.rs +++ b/crates/compose/src/cmyk.rs @@ -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 }; } diff --git a/crates/compose/src/cmyk/tests.rs b/crates/compose/src/cmyk/tests.rs index 44b265c8dd..894b87fd29 100644 --- a/crates/compose/src/cmyk/tests.rs +++ b/crates/compose/src/cmyk/tests.rs @@ -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); + } +}