diff --git a/crates/compose/proptest-regressions/cmyk_tests.txt b/crates/compose/proptest-regressions/cmyk_tests.txt index 23a4b6e958..2f3a9c3fb8 100644 --- a/crates/compose/proptest-regressions/cmyk_tests.txt +++ b/crates/compose/proptest-regressions/cmyk_tests.txt @@ -5,3 +5,5 @@ # It is recommended to check this file in to source control so that # 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 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 }] } diff --git a/crates/compose/src/cmyk.rs b/crates/compose/src/cmyk.rs index da1cb51fdb..52db6582d9 100644 --- a/crates/compose/src/cmyk.rs +++ b/crates/compose/src/cmyk.rs @@ -402,12 +402,24 @@ fn adjust_inks(adj: &Adjustment, buf: &mut CmykBuffer, depth: SampleType) { /// mask; coverage is unchanged (the compositor's adjustment mix). fn mix_adjusted(l: &Layer, base: &mut CmykBuffer, adjusted: &CmykBuffer, cx: &Ctx) { 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() { let k = opacity * mask_at(&mv, i); if k <= 0.0 { 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]]); for (v, b) in p.iter_mut().zip(bl) { *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 -/// channel brightness the document stores, and coverage). -fn quantize_inks(buf: &mut CmykBuffer, q: f32) { +/// channel brightness the document stores, and coverage). Also used by [`super::native`]. +pub(crate) fn quantize_inks(px: &mut [Cmyka], q: f32) { 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) { // Whole levels of ink: exactly the stored values, rounding ties as on brightness. *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 opacity = c.opacity * c.fill_opacity; 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]; if alpha <= 0.0 { 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); *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 { composite_layer(base, clipped, backdrop, cx); if let (LayerContent::Adjustment(_), Some(q)) = (&base.content, super::adjustment_quantum(cx.depth)) { - quantize_inks(backdrop, q); + quantize_inks(&mut backdrop.px, q); } } } diff --git a/crates/compose/src/cmyk_tests.rs b/crates/compose/src/cmyk_tests.rs index 41232675c0..3e28fccd7e 100644 --- a/crates/compose/src/cmyk_tests.rs +++ b/crates/compose/src/cmyk_tests.rs @@ -219,6 +219,10 @@ fn ref_adjusted(l: &Layer, adj: &Adjustment, clipped: &[Layer], p: Cmyka, x: i32 a = ref_clip(clipped, a, 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; for c in 0..4 { 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); } else if content[4] > 0.0 { 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]; } } @@ -1697,3 +1701,100 @@ fn rgb_colours_become_ink_through_the_document_profile() { assert!(!supports(&overlay) && split::supports(&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, 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}"); + } +} diff --git a/crates/compose/src/native.rs b/crates/compose/src/native.rs index f837842920..98f87f75a9 100644 --- a/crates/compose/src/native.rs +++ b/crates/compose/src/native.rs @@ -214,7 +214,13 @@ impl<'a> InkStack<'a> { } } 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 let Some(q) = quantum { + crate::cmyk::quantize_inks(acc, q); + } continue; } masked(*mask, mv); @@ -228,6 +234,9 @@ impl<'a> InkStack<'a> { d[c] += (v - d[c]) * mix; } } + if let Some(q) = quantum { + crate::cmyk::quantize_inks(acc, q); + } } } }