diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 52d90f3..b4ada12 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -765,18 +765,23 @@ impl Renderer { } } if let Some(r) = soft { - // Feather: fade the shape's alpha towards the outline. + // Feather: the shape (fill and line) fades to nothing at its outline. Measured on + // PowerPoint's export: the mask is the outline shrunk by 0.85 r, blurred by a third of + // r, so it's half opaque 0.85 r inside the edge and transparent at the edge itself. ctx.set_transform(m); ctx.push_layer( None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestIn)), None, None, - Some(blur(r * 0.5 * scale_of(m))), + Some(blur(r / 3.0 * scale_of(m))), ); ctx.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); - let inset = Rect::new(0.0, 0.0, w, h).inset(-r * 0.6); - ctx.fill_rect(&inset); + ctx.fill_path(&outline); + ctx.push_blend_layer(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::DestOut)); + ctx.set_stroke(kurbo::Stroke::new(r * 1.7).with_join(kurbo::Join::Round)); + ctx.stroke_path(&outline); + ctx.pop_layer(); ctx.pop_layer(); ctx.pop_layer(); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 58ba965..cfe4dc9 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -846,3 +846,29 @@ fn rect_path_gradients_draw_x_shaped_contours() { // Along the diagonal to a corner the colour follows the vertical share. assert!((v(168, 120) - 51.0).abs() < 6.0, "{}", v(168, 120)); } + +#[test] +fn soft_edges_fade_to_the_outline() { + // An ellipse with a 12 pt soft edge: transparent at its outline, half opaque about 10 pt + // inside, opaque further in, as in PowerPoint's export (the fade follows the curve, not the + // bounding box). + let sh = Shape { + id: ShapeId(780), + xfrm: Some(Xfrm::new(100.0, 100.0, 300.0, 200.0)), + geom: Geom::Preset { name: "ellipse".into(), adj: vec![] }, + fill: Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)))), + line: Some(deckcraft_model::style::Line { fill: Some(deckcraft_model::style::Fill::None), ..Default::default() }), + effects: Some(deckcraft_model::Effects { soft_edge: Some(12.0), ..Default::default() }), + ..Default::default() + }; + let p = deck_with(vec![sh]); + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let alpha = |x: u32, y: u32| 1.0 - img.pixel(x, y)[0] as f64 / 255.0; + // Top of the ellipse is at y = 100, x = 250. + assert!(alpha(250, 101) < 0.08, "edge {}", alpha(250, 101)); + let mid = alpha(250, 110); + assert!((0.3..0.7).contains(&mid), "0.85 r inside: {mid}"); + assert!(alpha(250, 130) > 0.97, "inside {}", alpha(250, 130)); + // Near the ends of the bounding box's top edge, outside the ellipse, nothing is drawn. + assert!(alpha(110, 105) < 0.01); +}