diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6dde577a..fa9eb33a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -668,7 +668,9 @@ impl Renderer { let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill }; // Effects below the shape. if let Some(e) = &effects { - self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, m, w, h); + // A shape without a fill (an outline, a frame) glows along its line only. + let filled = is_pic || fill.as_ref().is_some_and(|f| !matches!(f, Fill::None)); + self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, filled, m, w, h); } let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0); if let Some(r) = soft { @@ -801,6 +803,7 @@ impl Renderer { ph: Option, geo: &Geometry, fill: &Option, + filled: bool, m: Affine, w: f64, h: f64, @@ -837,9 +840,15 @@ impl Renderer { ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(g.radius * 0.35 * 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.stroke_path(&silhouette); - ctx.fill_path(&silhouette); + if filled { + ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 1.2).with_join(kurbo::Join::Round)); + ctx.stroke_path(&silhouette); + ctx.fill_path(&silhouette); + } else { + // The glow spreads its radius to both sides of the line; the inside stays clear. + ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 2.0).with_join(kurbo::Join::Round)); + ctx.stroke_path(&silhouette); + } ctx.pop_layer(); } if let Some(rf) = &e.reflection { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ccb4472f..08cee36a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -124,6 +124,34 @@ fn effects_tables_charts_render() { assert!(px[0] < 250, "{px:?}"); } +#[test] +fn glow_of_an_unfilled_shape_follows_its_line() { + // A frame (no fill, thin line) with a strong glow: the glow lines its edge, the inside stays + // the slide's white, as PowerPoint draws it. + let glow = |fill: Option| { + let mut sh = Shape { + id: ShapeId(610), + xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 300.0)), + fill, + line: Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 240)), 0.25)), + ..Default::default() + }; + sh.effects = Some(deckcraft_model::Effects { + glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 255)), radius: 8.0 }), + ..Default::default() + }); + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let img = glow(Some(Fill::None)); + let inside = img.pixel(300, 250); + assert!(inside[0] > 245 && inside[1] > 245, "{inside:?}"); + let edge = img.pixel(300, 97); + assert!(edge[0] < 230, "{edge:?}"); + // A filled shape's glow is still the whole silhouette's (the fill covers its inside). + let img = glow(Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255))))); + assert!(img.pixel(300, 94)[0] < 240); +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default();