From e22b6a5d450b87673b3c012f482f2562eb5b646d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 03:02:39 -0700 Subject: [PATCH] Render: a knockout group drawn over its backdrop counts an element's partial coverage once A knockout group that is not isolated, and whose elements blend, is drawn over a copy of the art below it when the renderer has no layer open around it. Each element was drawn over the whole copy in a layer, and the layer was cut to the element's knockout shape with DestIn. Where the shape covers part of a pixel, the cut counted the element's coverage a second time. With Page Knockout Group on and Page Isolated Blending off, the page is drawn this way as soon as an object on it outside an isolated group has a blend mode other than Normal, on itself or on a fill or stroke. On a test page, adding a Multiply square changed the half-covered edge of a blue square over orange from [115, 115, 141] to [172, 159, 147] on white. Each element is now drawn over the copy offscreen, over its own bounds. With the DestOut pass before it, each pixel gets (1 - shape) times what was below plus shape times the element composited with the copy at a shape of 1, as ISO 32000-1 section 11.4.6 describes. Pixels that the shape covers fully or not at all are drawn as before. --- crates/render/src/group.rs | 72 +++++++++++++++++++--------- crates/render/src/lib.rs | 9 ++-- crates/render/src/tests_isolation.rs | 28 +++++++++++ crates/render/src/tests_knockout.rs | 58 ++++++++++++++++++++++ 4 files changed, 142 insertions(+), 25 deletions(-) diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs index ad9678887..e32be592d 100644 --- a/crates/render/src/group.rs +++ b/crates/render/src/group.rs @@ -137,17 +137,7 @@ impl Renderer { fn flatten(&mut self, ctx: &mut RenderContext, r: Region) -> Arc { let all = self.finish(ctx, self.raster); ctx.reset(); - let crop = if (r.x, r.y, r.w, r.h) == (0, 0, all.width(), all.height()) { - None - } else { - let mut out = Pixmap::new(r.w, r.h); - let (src_w, w) = (all.width() as usize, r.w as usize); - for row in 0..r.h as usize { - let from = (r.y as usize + row) * src_w + r.x as usize; - out.data_mut()[row * w..(row + 1) * w].copy_from_slice(&all.data()[from..from + w]); - } - Some(Arc::new(out)) - }; + let crop = if (r.x, r.y, r.w, r.h) == (0, 0, all.width(), all.height()) { None } else { Some(Arc::new(cropped(&all, r))) }; let all = Arc::new(all); // With 8-bit compositing, rendering over the picture gives the pixels that drawing it back // as an image gives: the context starts again empty, and `finish` renders what it draws @@ -241,27 +231,41 @@ impl Renderer { } /// Draw knockout element `c` in a layer composited with `compose`: as its knockout shape - /// (`DestOut` erases what the elements below drew there, `DestIn` keeps only that part), or - /// as itself (`Plus` adds it). In a non-isolated knockout group (`backdrop`), the element is - /// laid over the group's backdrop and cut to its shape, so it composites against the art - /// below the group instead of the elements below it. - pub(crate) fn knockout_pass(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc, compose: Compose, backdrop: Option<&Arc>) { + /// (`DestOut` erases what the elements below drew there, `SrcOver` draws the shape), or as + /// itself (`Plus` adds it). + pub(crate) fn knockout_pass(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc, compose: Compose) { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, compose)), None, None, None); self.nested += 1; - if let Some(b) = backdrop { - draw_pixmap(ctx, b.clone(), (0.0, 0.0)); - } let shape = compose != Compose::Plus && !c.knockout_shape; self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 }; self.draw_arc(ctx, f, c); self.shape_of = 0; - if backdrop.is_some() { - self.knockout_pass(ctx, f, c, Compose::DestIn, None); - } self.nested -= 1; ctx.pop_layer(); } + + /// Add knockout element `c` of a non-isolated knockout group drawn over a copy of its backdrop + /// (`backdrop`, in `ctx`'s pixels), once its `DestOut` pass has erased what the elements below + /// it drew: `c` composited with the backdrop at a shape of 1, times its knockout shape + /// (ISO 32000-1 §11.4.6). That is `c` drawn over the backdrop, less (1 − shape) × backdrop. + /// `c` over the backdrop and its shape are drawn offscreen over `c`'s bounds, and `c` is drawn + /// as in the other knockout passes (groups in it can't copy their backdrop). + pub(crate) fn knockout_over(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc, backdrop: &Arc) { + let bounds = self.bounds_of(f.symbols, c); + let Some(r) = region(ctx, f, bounds) else { return }; + let view = Affine::translate((-(r.x as f64), -(r.y as f64))) * f.view; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, r.w as f64, r.h as f64)); + let sub = Frame { mt: false, view, visible, ..*f }; + let whole = (r.x, r.y, r.w, r.h) == (0, 0, backdrop.width(), backdrop.height()); + let below = if whole { backdrop.clone() } else { Arc::new(cropped(backdrop, r)) }; + let over = self.offscreen(r, &sub, Some(below.clone()), &mut |s, cx, fr| s.inside_layer(|s| s.draw_arc(cx, fr, c))); + let shape = self.offscreen(r, &sub, None, &mut |s, cx, fr| s.knockout_pass(cx, fr, c, Compose::SrcOver)); + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Plus)), None, None, None); + draw_pixmap(ctx, Arc::new(inside_shape(over, &shape, &below)), (r.x as f64, r.y as f64)); + ctx.pop_layer(); + } } /// Draw `pm` with its top-left corner at pixel `at` (whole pixels: copied exactly). @@ -293,6 +297,17 @@ fn region(ctx: &RenderContext, f: &Frame, bounds: Option) -> Option x0 && y1 > y0).then_some(Region { x: x0 as u16, y: y0 as u16, w: (x1 - x0) as u16, h: (y1 - y0) as u16 }) } +/// The pixels of region `r` of `pm`. +fn cropped(pm: &Pixmap, r: Region) -> Pixmap { + let mut out = Pixmap::new(r.w, r.h); + let (src_w, w) = (pm.width() as usize, r.w as usize); + for row in 0..r.h as usize { + let from = (r.y as usize + row) * src_w + r.x as usize; + out.data_mut()[row * w..(row + 1) * w].copy_from_slice(&pm.data()[from..from + w]); + } + out +} + /// The group's own colour from `content` (the group drawn over `backdrop`) and `alone` (drawn /// over transparency, for its coverage): content = group + (1 − group alpha) × backdrop, all /// premultiplied. @@ -304,3 +319,16 @@ fn remove_backdrop(mut content: Pixmap, alone: &Pixmap, backdrop: &Pixmap) -> Pi } content } + +/// What a knockout element adds once its knockout shape has erased what was below it: `over` (the +/// element drawn over `backdrop`) less (1 − shape alpha) × `backdrop`, with `shape` the knockout +/// shape, all premultiplied. Each color is kept within the alpha left. +fn inside_shape(mut over: Pixmap, shape: &Pixmap, backdrop: &Pixmap) -> Pixmap { + for ((o, s), b) in over.data_mut().iter_mut().zip(shape.data()).zip(backdrop.data()) { + let outside = 255 - s.a as u32; + let sub = |v: u8, bv: u8| (v as u32).saturating_sub((outside * bv as u32 + 127) / 255); + let a = sub(o.a, b.a); + (o.r, o.g, o.b, o.a) = (sub(o.r, b.r).min(a) as u8, sub(o.g, b.g).min(a) as u8, sub(o.b, b.b).min(a) as u8, a as u8); + } + over +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f5d9562d3..8856b10a2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1359,15 +1359,18 @@ impl Renderer { /// object opacity without its opacity mask, or as drawn when its opacity and mask define the /// knockout shape), then adds itself: it composites against the group's backdrop instead of /// over the elements below it (the art below the group when the group is non-isolated and - /// drawn over a copy of it, see [`Self::group`]). + /// drawn over a copy of it, see [`Self::group`] and [`Self::knockout_over`]). fn draw_knockout(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc]) { let backdrop = self.backdrop.take(); for c in Node::knockout_elements(children) { if self.skipped(f, c) { continue; } - self.knockout_pass(ctx, f, c, Compose::DestOut, None); - self.knockout_pass(ctx, f, c, Compose::Plus, backdrop.as_ref()); + self.knockout_pass(ctx, f, c, Compose::DestOut); + match &backdrop { + Some(b) => self.knockout_over(ctx, f, c, b), + None => self.knockout_pass(ctx, f, c, Compose::Plus), + } } self.backdrop = backdrop; } diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 8fb1d6950..7b2296920 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -186,6 +186,34 @@ fn a_non_isolated_knockout_group_composites_its_children_against_the_backdrop() } } +#[test] +fn a_knockout_group_weights_a_partly_covered_pixel_by_the_coverage_once() { + // The upper Multiply square's left edge covers half of pixel column 40. There the upper square + // takes half of the pixel with its color composited against the backdrop, and the lower one + // keeps the other half (ISO 32000-1 §11.4.6). Right of x = 70 the backdrop is a stripe of + // another color, which the upper square multiplies with. + const T: [f32; 3] = [1.0, 0.4, 0.6]; + const STRIPE: [f32; 3] = [0.3, 0.7, 0.9]; + for isolate in [false, true] { + let mut d = doc(|d| { + let mut lower = rect(d, Rect::new(10.0, 10.0, 60.0, 90.0), S); + let mut upper = rect(d, Rect::new(40.5, 10.0, 90.0, 90.0), T); + (lower.blend, upper.blend) = (Blend::Multiply, Blend::Multiply); + let mut g = group(d, vec![lower, upper], 1.0, Blend::Normal, isolate); + g.knockout = Knockout::On; + g + }); + let stripe = rect(&mut d, Rect::new(70.0, 0.0, 100.0, 100.0), STRIPE); + let l = d.layers[0].id; + d.insert(Some(l), 1, stripe).unwrap(); + let r = render(&d); + // Isolated, each square multiplies with nothing and shows its own color. + let multiplied = |b: [f32; 3], c: [f32; 3]| if isolate { c } else { blend_rgb(Blend::Multiply, b, c) }; + assert_near(r.pixel(40, 50), lerp(multiplied(B, S), multiplied(B, T), 0.5), &format!("half-covered edge, isolate {isolate}")); + assert_near(r.pixel(80, 50), multiplied(STRIPE, T), &format!("over the stripe, isolate {isolate}")); + } +} + #[test] fn trim_view_page_group_and_threads_keep_the_non_isolated_look() { let d = doc(|d| { diff --git a/crates/render/src/tests_knockout.rs b/crates/render/src/tests_knockout.rs index d364080f5..1fbb67f7a 100644 --- a/crates/render/src/tests_knockout.rs +++ b/crates/render/src/tests_knockout.rs @@ -126,3 +126,61 @@ fn page_knockout_and_isolated_blending() { d.page_isolate = true; assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50)); } + +#[test] +fn page_knockout_looks_the_same_when_another_object_blends() { + // With Page Knockout Group on, a Multiply square on the page makes the renderer draw the page + // over a copy of what is below it (white, or nothing). Four objects knock out part of the + // orange page: a blue square whose left edge covers half of pixel column 10, a 50% blue square + // whose opacity defines its knockout shape, a 50% blue square, and an isolated group of a green + // square and a 50% group holding a Multiply square, which draws its middle half transparent. + // Each looks the same with and without the Multiply square. + let rect = |d: &mut Document, r: Rect, (red, green, blue): (f32, f32, f32)| { + Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(red, green, blue)), Paint::None, 0.0)) + }; + let page = |blending: bool| { + let mut d = Document::new(100.0, 100.0); + d.page_knockout = true; + let orange = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), (0.8, 0.6, 0.2)); + let edge = rect(&mut d, Rect::new(10.5, 10.0, 30.5, 30.0), (0.1, 0.3, 0.9)); + let mut shaped = rect(&mut d, Rect::new(40.0, 10.0, 60.0, 30.0), (0.1, 0.3, 0.9)); + (shaped.opacity, shaped.knockout_shape) = (0.5, true); + let mut translucent = rect(&mut d, Rect::new(70.0, 10.0, 90.0, 30.0), (0.1, 0.3, 0.9)); + translucent.opacity = 0.5; + let green = rect(&mut d, Rect::new(10.0, 40.0, 60.0, 90.0), (0.2, 0.9, 0.4)); + let mut multiply = rect(&mut d, Rect::new(20.0, 50.0, 50.0, 80.0), (0.5, 0.5, 1.0)); + multiply.blend = Blend::Multiply; + let mut inner = Node::group(d.alloc_id(), vec![Arc::new(multiply)]); + inner.opacity = 0.5; + let mut isolated = Node::group(d.alloc_id(), vec![Arc::new(green), Arc::new(inner)]); + isolated.isolate = true; + let mut objects = vec![orange, edge, shaped, translucent, isolated]; + if blending { + let mut other = rect(&mut d, Rect::new(70.0, 70.0, 90.0, 90.0), (0.5, 0.5, 1.0)); + other.blend = Blend::Multiply; + objects.push(other); + } + let l = d.layers[0].id; + for n in objects { + d.insert(Some(l), usize::MAX, n).unwrap(); + } + d + }; + // Half of the orange stays and blue takes the other half: 0.5 · orange + 0.5 · blue. The 50% + // square knocks out all of the orange and shows over what is below the page: 0.5 · white + + // 0.5 · blue, or blue at 50% over nothing. + let half_blue = [115, 115, 140, 255]; + for (below, half_square) in [(Some([255, 255, 255, 255]), [140, 166, 242, 255]), (None, [26, 77, 230, 128])] { + let opts = RenderOptions { background: below, ..Default::default() }; + let draw = |d: &Document| Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts); + let (plain, blending) = (draw(&page(false)), draw(&page(true))); + let cases = [("half-covered edge", 10, 20, half_blue), ("knockout shape", 50, 20, half_blue), ("50%", 80, 20, half_square)]; + for (what, x, y, want) in cases { + for r in [&plain, &blending] { + assert!(close(r.pixel(x, y), want), "{what} over {below:?}: {:?}", r.pixel(x, y)); + } + } + let (a, b) = (plain.pixel(35, 65), blending.pixel(35, 65)); + assert!(close(a, b), "the group's middle over {below:?}: {a:?} alone, {b:?} with the Multiply square"); + } +}