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) <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-13 08:16:41 -04:00
1 parent aafce0d2ef
commit 84e6c12543
2 files changed
+51

No files matched your search

+27
View File
@@ -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);
}
+24
View File
@@ -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");
}