diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b4ada12..b493889 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -793,11 +793,14 @@ impl Renderer { ctx.push_clip_layer(&outline); let c = rctx.color(&sh.color, fx_ph); let d = Vec2::new(sh.dir.to_radians().cos(), sh.dir.to_radians().sin()) * sh.dist; - ctx.push_layer(None, None, None, None, Some(blur(sh.blur * 0.5 * scale_of(m)))); + // As PowerPoint draws it (measured on its export): the shadow falls inside along the + // edges it points to (45 degrees: the bottom and right), blurred by a third of the + // blur radius. + ctx.push_layer(None, None, None, None, Some(blur(sh.blur / 3.0 * scale_of(m)))); ctx.set_paint(color(c, 1.0)); let mut ring = Rect::new(0.0, 0.0, w, h).inflate(w.max(h), w.max(h)).to_path(0.1); let mut hole = outline.clone(); - hole.apply_affine(Affine::translate(d)); + hole.apply_affine(Affine::translate(-d)); ring.extend(hole.iter()); ctx.set_fill_rule(peniko::Fill::EvenOdd); ctx.fill_path(&ring); @@ -868,7 +871,8 @@ impl Renderer { // Shadow offset is in slide space (doesn't rotate with the shape unless asked). let sm = if sh.rotate_with_shape { m * Affine::translate(d) * local } else { pre_translate(m, d * k) * local }; ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur * 0.5 * k))); + // Blurred by a third of the blur radius, as PowerPoint's export shows. + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k))); ctx.set_transform(sm); ctx.set_paint(color(c.with_alpha(255), 1.0)); ctx.fill_path(&silhouette); @@ -877,10 +881,12 @@ impl Renderer { if let Some(g) = &e.glow { let c = rctx.color(&g.color, ph); ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * k))); + // Measured on PowerPoint's export: a glow of radius r is half strength about half of r + // out from the outline and gone by r. + ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.17 * k))); ctx.set_transform(m); ctx.set_paint(color(c.with_alpha(255), 1.0)); - ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round)); + ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round)); ctx.stroke_path(&silhouette); ctx.fill_path(&silhouette); ctx.pop_layer(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 5f87689..80efa43 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -931,3 +931,65 @@ fn a_table_fill_shows_behind_cells_without_their_own() { assert_eq!(img.pixel(150, 160)[..3], [0, 112, 192]); assert_eq!(img.pixel(50, 50)[..3], [255, 255, 255]); } + +/// A white 280x200 pt rectangle at (20, 20) with `effects`, rendered at 1 px/pt. +fn effect_box(effects: deckcraft_model::Effects) -> Image { + use deckcraft_model::style::{Fill, Line}; + let sh = Shape { + id: ShapeId(800), + xfrm: Some(Xfrm::new(20.0, 20.0, 280.0, 200.0)), + fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))), + line: Some(Line { fill: Some(Fill::None), ..Default::default() }), + effects: Some(effects), + ..Default::default() + }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +fn shadow(blur: f64, dist: f64, dir: f64) -> deckcraft_model::style::Shadow { + deckcraft_model::style::Shadow { + color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), + blur, + dist, + dir, + inner: false, + sx: 1.0, + sy: 1.0, + kx: 0.0, + ky: 0.0, + align: String::new(), + rotate_with_shape: false, + } +} + +#[test] +fn shadows_and_glows_spread_like_powerpoints() { + let red = |img: &Image, x: u32, y: u32| img.pixel(x, y)[0] as i32; + // Outer shadow, 9 pt blur, no offset: PowerPoint's export reads 144, 204, 238 at 1, 3 and 5 + // px outside the left edge. + let img = effect_box(deckcraft_model::Effects { outer_shadow: Some(shadow(9.0, 0.0, 0.0)), ..Default::default() }); + for (d, want) in [(1, 144), (3, 204), (5, 238)] { + assert!((red(&img, 20 - d, 120) - want).abs() <= 6, "outer {d}: {}", red(&img, 20 - d, 120)); + } + // Inner shadow pointing down-right (45 degrees): it falls inside along the bottom and right + // edges, not the top and left. + let mut sh = shadow(0.0, 10.0, 45.0); + sh.inner = true; + let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() }); + assert!(red(&img, 296, 120) < 20, "right edge {}", red(&img, 296, 120)); + assert!(red(&img, 160, 216) < 20, "bottom edge {}", red(&img, 160, 216)); + assert!(red(&img, 24, 120) > 250 && red(&img, 160, 24) > 250, "top and left stay clear"); + // Inner shadow with a 9 pt blur and no offset: the same profile as the outer one, inside. + let mut sh = shadow(9.0, 0.0, 0.0); + sh.inner = true; + let img = effect_box(deckcraft_model::Effects { inner_shadow: Some(sh), ..Default::default() }); + for (d, want) in [(0, 144), (2, 204), (4, 238)] { + assert!((red(&img, 20 + d, 120) - want).abs() <= 6, "inner {d}: {}", red(&img, 20 + d, 120)); + } + // Glow of radius 10 pt: nearly solid 1 px out, about half strength 5 px out, gone by 10. + let glow = deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)), radius: 10.0 }; + let img = effect_box(deckcraft_model::Effects { glow: Some(glow), ..Default::default() }); + assert!(red(&img, 19, 120) < 20); + assert!((60..170).contains(&red(&img, 15, 120)), "{}", red(&img, 15, 120)); + assert!(red(&img, 10, 120) > 245); +}