diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5b47c8bce2..9072be3f08 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -764,6 +764,13 @@ impl RenderState { Self::union_stroke_outset(base, stroke_outset) } + /// Fill ∪ outer stroke outline in shape-local space: the area a drop + /// shadow must not show under (vector, SVG). + pub(crate) fn drop_shadow_clear_path(shape: &Shape) -> skia::Path { + let stroke_outset = Stroke::max_bounds_width(shape.visible_strokes(), shape.is_open()); + Self::background_blur_clip_path(shape, stroke_outset) + } + /// Unions a centered stroke of width `2 * stroke_outset` into `base`. /// The inward half disappears in the union, leaving `stroke_outset` beyond /// the fill outline. @@ -3431,20 +3438,11 @@ impl RenderState { return self.render_masked_group_silhouette(shape, clip_bounds, pass); } - let flat = Shadow::new( - skia::Color::BLACK, - 0.0, - 0.0, - (0.0, 0.0), - crate::shapes::ShadowStyle::Drop, - false, - ); - self.render_drop_black_shadow( shape, pass.tree, &shape.extrect(pass.tree, pass.scale), - &flat, + &Shadow::silhouette(), clip_bounds.clone(), pass.scale, pass.extra_layer_blur, @@ -3649,7 +3647,19 @@ impl RenderState { // High zoom with blur: use render_into_filter_surface to ensure blur has enough space // Apply spread geometrically to avoid dilate filter rounding issues let layer_blur_value = combined_blur.map(|b| b.value).unwrap_or(0.0); - let cache_key = clip_bounds.is_none().then(|| { + + // Only the part that can blur into this tile matters. A shadow bigger + // than the filter surface would otherwise be cut at MIN_FIT_SCALE. + let mut filter_bounds = bounds; + if target_surface != SurfaceId::Export { + let reach = 2.0 * (shadow.blur + layer_blur_value) + 1.0; + let tile_reach = self.render_area_with_margins.with_outset((reach, reach)); + if !filter_bounds.intersect(tile_reach) { + return Ok(()); + } + } + + let cache_key = (clip_bounds.is_none() && filter_bounds == bounds).then(|| { shadows::DropShadowFilterCacheKey::for_shape( shape.id, shadow, @@ -3682,7 +3692,7 @@ impl RenderState { // Apply spread geometrically to avoid dilate filter rounding issues let filter_result = filters::render_into_filter_surface( self, - bounds, + filter_bounds, blur_downscale, |state, temp_surface| { let canvas = state.surfaces.canvas(temp_surface); @@ -3712,7 +3722,8 @@ impl RenderState { )?; if let Some((mut surface, filter_scale)) = filter_result { - let cached = shadows::CachedDropShadowFilter::new(bounds, filter_scale, &mut surface); + let cached = + shadows::CachedDropShadowFilter::new(filter_bounds, filter_scale, &mut surface); shadows::blit_cached_drop_shadow_filter( &mut self.surfaces, &cached, @@ -3819,6 +3830,27 @@ impl RenderState { .canvas(SurfaceId::DropShadows) .draw_paint(&paint); + // Like CSS box-shadow, the shadow never shows under the shape. + if element.clears_drop_shadow_underneath() { + let mut paint = skia::Paint::default(); + paint.set_blend_mode(skia::BlendMode::DstOut); + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); + self.surfaces + .canvas(SurfaceId::DropShadows) + .save_layer(&layer_rec); + self.render_drop_black_shadow( + element, + tree, + element_extrect, + &Shadow::silhouette(), + clip_bounds.clone(), + scale, + None, + target_surface, + )?; + self.surfaces.canvas(SurfaceId::DropShadows).restore(); + } + self.surfaces.canvas(SurfaceId::DropShadows).restore(); } diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index 1fbdd11615..703308c19e 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -5,6 +5,7 @@ use crate::shapes::{radius_to_sigma, Fill, Shadow, Shape, Type}; use crate::state::ShapesPoolRef; use crate::render::vector::draw_shape_geometry; +use crate::render::RenderState; // Skia's SVG backend (`SkSVGDevice`) silently drops everything drawn inside a // `save_layer`, so composite effects rendered with `save_layer` (opacity, @@ -222,6 +223,35 @@ impl SvgLayerCanvas { self.finish_clip_path_fragment(id, canvas); } + /// Opens a group whose mask hides `element`'s outline, so its drop shadows + /// never show under it (CSS `box-shadow`). Returns whether it opened one. + pub(super) fn open_drop_shadow_clear_group(&mut self, element: &Shape) -> bool { + if !element.clears_drop_shadow_underneath() { + return false; + } + let id = self.unique("dsmask"); + let canvas = self.new_fragment(); + { + let cv: &skia::Canvas = &canvas; + let mut paint = Paint::default(); + paint.set_color(skia::Color::WHITE); + cv.save(); + cv.reset_matrix(); + cv.draw_rect( + skia::Rect::from_wh(self.page_rect.width(), self.page_rect.height()), + &paint, + ); + cv.restore(); + paint.set_anti_alias(true); + paint.set_color(skia::Color::BLACK); + cv.concat(&element.centered_transform()); + cv.draw_path(&RenderState::drop_shadow_clear_path(element), &paint); + } + self.finish_mask_fragment(&id, canvas); + self.open_group(&format!("mask=\"url(#{id})\"")); + true + } + /// Finalizes a fragment canvas as a `` def. /// /// Rewrite fill-rule to clip-rule: clipPaths ignore fill-rule, so evenodd diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs index fa3bb808f6..de51c6abaf 100644 --- a/render-wasm/src/render/svg/frames.rs +++ b/render-wasm/src/render/svg/frames.rs @@ -34,6 +34,7 @@ pub(super) fn render_frame( // (GPU), filter only blurs/tints — keeps stroke-ring width and rotation. if !builder.suppress_filters { let drops: Vec = element.drop_shadows_visible().copied().collect(); + let cleared = !drops.is_empty() && builder.open_drop_shadow_clear_group(element); for shadow in &drops { let id = push_container_drop_filter(builder, shadow); builder.open_group(&format!("filter=\"url(#{id})\"")); @@ -49,6 +50,9 @@ pub(super) fn render_frame( builder.suppress_filters = prev_suppress; builder.close_group(); } + if cleared { + builder.close_group(); + } } let effects = content_effect_attrs(builder, element); diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index 997a68e71c..fee187db54 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -298,6 +298,7 @@ fn render_leaf( // feMorphology rounding on circles). Filter only blurs/tints. if !builder.suppress_filters { let drops: Vec = element.drop_shadows_visible().copied().collect(); + let cleared = !drops.is_empty() && builder.open_drop_shadow_clear_group(element); for shadow in &drops { let id = push_container_drop_filter(builder, shadow); builder.open_group(&format!("filter=\"url(#{id})\"")); @@ -313,6 +314,9 @@ fn render_leaf( builder.suppress_filters = prev_suppress; builder.close_group(); } + if cleared { + builder.close_group(); + } } let effects = content_effect_attrs(builder, element); diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 490d5ef27a..2212371f1e 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -577,6 +577,43 @@ fn exports_leaf_drop_shadow_as_svg_filter() { insta::assert_snapshot!(svg); } +fn render_rect_with_drop_shadow(fill: skia::Color) -> String { + let mut pool = ShapesPool::new(); + let id = uid(1); + add_solid_rect(&mut pool, id, Uuid::nil(), (0.0, 0.0, 100.0, 80.0), fill); + pool.get_mut(&id).unwrap().add_shadow(Shadow::new( + skia::Color::BLACK, + 8.0, + 0.0, + (4.0, 6.0), + ShadowStyle::Drop, + false, + )); + render(&pool, id) +} + +#[test] +fn translucent_leaf_hides_drop_shadow_underneath() { + let svg = render_rect_with_drop_shadow(skia::Color::from_argb(100, 255, 0, 0)); + let mask_at = svg + .find("mask=\"url(#dsmask") + .unwrap_or_else(|| panic!("drop shadow must be masked under a translucent fill: {svg}")); + let filter_at = svg.find("filter=\"url(#fx").expect("drop filter"); + assert!( + mask_at < filter_at, + "the mask must wrap the drop shadow group: {svg}" + ); +} + +#[test] +fn opaque_leaf_keeps_drop_shadow_unmasked() { + let svg = render_rect_with_drop_shadow(skia::Color::from_rgb(255, 0, 0)); + assert!( + !svg.contains("dsmask"), + "an opaque fill hides the shadow already: {svg}" + ); +} + #[test] fn leaf_drop_offset_follows_rotation_in_user_space() { let mut pool = ShapesPool::new(); diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index 5bb8c4795f..9dbc5651a5 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use skia_safe::{self as skia, Canvas, Paint, RRect}; +use skia_safe::{self as skia, Canvas, Matrix, Paint, RRect}; use crate::error::Result; use crate::shapes::{ @@ -129,6 +129,8 @@ impl ShapeRenderer for VectorRenderer<'_> { fn draw_drop_shadows(&mut self, shape: &Shape) -> Result<()> { let layer_bounds = shape.layer_bounds(); + self.canvas.save(); + clip_out_drop_shadow_underneath(self.canvas, shape, &Matrix::default()); for shadow in shape.drop_shadows_visible() { if let Some(filter) = shadow.get_drop_shadow_blur_filter() { let mut paint = Paint::default(); @@ -150,6 +152,7 @@ impl ShapeRenderer for VectorRenderer<'_> { self.canvas.restore(); } } + self.canvas.restore(); Ok(()) } @@ -1026,6 +1029,8 @@ fn render_container_drop_shadows( opts: &TreeOpts, ) -> Result<()> { let subtree_bounds = element.extrect(tree, scale); + canvas.save(); + clip_out_drop_shadow_underneath(canvas, element, &element.centered_transform()); for shadow in element.drop_shadows_visible() { let Some(filter) = shadow.get_drop_shadow_blur_filter() else { continue; @@ -1064,9 +1069,19 @@ fn render_container_drop_shadows( canvas.restore(); } + canvas.restore(); Ok(()) } +/// Like CSS `box-shadow`, a drop shadow never shows under its shape. +/// `local_to_canvas` maps shape-local space to the current canvas space. +fn clip_out_drop_shadow_underneath(canvas: &Canvas, shape: &Shape, local_to_canvas: &Matrix) { + if shape.clears_drop_shadow_underneath() { + let path = RenderState::drop_shadow_clear_path(shape).make_transform(local_to_canvas); + canvas.clip_path(&path, skia::ClipOp::Difference, true); + } +} + /// `shape` grown by a drop-shadow `spread` for its silhouette, through its own /// strokes and joins (as the GPU does for paths). Empty frames and containers /// have no outline of their own to grow. diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 6a157a1c13..5802f9f6fb 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1497,6 +1497,12 @@ impl Shape { .filter(|shadow| shadow.style() == ShadowStyle::Drop && !shadow.hidden()) } + pub fn clears_drop_shadow_underneath(&self) -> bool { + !matches!(self.shape_type, Type::Text(_)) + && self.has_fills() + && !self.fills.iter().any(Fill::is_opaque) + } + #[allow(dead_code)] pub fn inner_shadows(&self) -> impl DoubleEndedIterator { self.shadows diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 15afbbbba7..525d848b7b 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -264,6 +264,16 @@ impl Fill { } } + pub fn is_opaque(&self) -> bool { + match self { + Fill::Solid(SolidColor(color)) => color.a() == 255, + Fill::LinearGradient(g) | Fill::RadialGradient(g) => { + g.opacity == 255 && g.colors.iter().all(|c| c.a() == 255) + } + Fill::Image(i) => i.opacity == 255, + } + } + pub fn with_full_opacity(&self) -> Fill { match self { Fill::Solid(SolidColor(color)) => Fill::Solid(SolidColor(skia::Color::from_argb( diff --git a/render-wasm/src/shapes/shadows.rs b/render-wasm/src/shapes/shadows.rs index 0a11dfdceb..d0e2864e9f 100644 --- a/render-wasm/src/shapes/shadows.rs +++ b/render-wasm/src/shapes/shadows.rs @@ -60,6 +60,18 @@ impl Shadow { self.hidden } + /// The bare shape silhouette: no offset, blur or spread. + pub fn silhouette() -> Self { + Self::new( + skia::Color::BLACK, + 0.0, + 0.0, + (0.0, 0.0), + ShadowStyle::Drop, + false, + ) + } + /// Approximate on-screen footprint (blur/spread + offset) at `scale` (zoom×dpr). #[inline] pub fn device_extent(&self, scale: f32) -> f32 {