From 84e6c12543b275f1c5cb27ef5bbb87c72cd256db Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 13 Oct 2026 06:20:00 -0400 Subject: [PATCH] Render: a group's outer shadow is cast by its children An outer shadow set on a group (grpSpPr effectLst) was ignored. PowerPoint casts it from everything in the group: the children are now drawn once more under the shadow's offset, scale and skew (anchored on the group's box), recoloured to the shadow colour and blurred, below the group. Wild synthetic set: combined-effects slide (shadowed group) 2.00 -> 0.98. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 27 +++++++++++++++++++++++++++ crates/render/src/tests_wild.rs | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 415ec36..8a00a19 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -788,6 +788,33 @@ impl Renderer { Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))), }; let outer = std::mem::replace(&mut self.group_fill, own); + // A group's outer shadow is cast by everything in it, as PowerPoint draws it: the + // children drawn once more under the shadow's offset, scale and skew, recoloured to + // the shadow colour and blurred, below the group. + if let Some(sh) = s.effects.as_ref().and_then(|e| e.outer_shadow.as_ref()) { + let c = rctx.color(&sh.color, None); + let k = scale_of(m); + let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; + let local = shadow_local(sh, x.w, x.h); + let (own, pre) = + if sh.rotate_with_shape { (Affine::translate(d) * local, Affine::IDENTITY) } else { (local, Affine::translate(d * k)) }; + let det = m.determinant(); + if det.is_finite() && det.abs() > 1e-12 { + let shift = pre * m * own * m.inverse(); + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); + for ch in children { + self.shape(ctx, f, rctx, ch, shift * gm, depth + 1); + } + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None); + ctx.set_paint(color(c.with_alpha(255), 1.0)); + let (w, h) = (f.opts.scale * f.pres.slide_size.width, f.opts.scale * f.pres.slide_size.height); + ctx.fill_rect(&Rect::new(-w, -h, 3.0 * w, 3.0 * h).inflate(1e4, 1e4)); + ctx.pop_layer(); + ctx.pop_layer(); + } + } for c in children { self.shape(ctx, f, rctx, c, gm, depth + 1); } diff --git a/crates/render/src/tests_wild.rs b/crates/render/src/tests_wild.rs index b87fd45..1611302 100644 --- a/crates/render/src/tests_wild.rs +++ b/crates/render/src/tests_wild.rs @@ -511,3 +511,27 @@ fn gradient_lines_and_their_heads_take_the_gradient() { let (top, bottom) = (img.pixel(200, 91), img.pixel(200, 109)); assert!(top[0] > 200 && top[2] < 60 && bottom[2] > 200 && bottom[0] < 60, "{top:?} {bottom:?}"); } + +#[test] +fn a_groups_shadow_is_cast_by_its_children() { + let child = |id: u32, x: f64| Shape { + id: ShapeId(id), + xfrm: Some(Xfrm::new(x, 0.0, 80.0, 100.0)), + fill: solid(RED), + line: no_line(), + ..Default::default() + }; + let g = Shape { + id: ShapeId(22), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + kind: ShapeKind::Group { children: vec![child(23, 0.0), child(24, 120.0)], child: Xfrm::new(0.0, 0.0, 200.0, 100.0) }, + effects: Some(offset_shadow()), + ..Default::default() + }; + let img = render(vec![g]); + // 20 pt to the right of each child: the shadow; in the gap only the left child's shadow shows. + assert!(close(img.pixel(190, 150), Rgba::BLACK, 30), "{:?}", img.pixel(190, 150)); + assert!(close(img.pixel(210, 150), WHITE, 10), "{:?}", img.pixel(210, 150)); + assert!(close(img.pixel(310, 150), Rgba::BLACK, 30), "{:?}", img.pixel(310, 150)); + assert!(close(img.pixel(150, 150), RED, 10), "children drawn over it"); +}