🐛 Keep drop shadows out from under translucent shapes (#12904)

* 🐛 Keep drop shadows out from under translucent shapes at every zoom

Cut the shape out of its drop shadow, as CSS box-shadow does, and stop
large shadows from being cut off at high zoom.

AI-assisted-by: claude-opus-5-5

* 🐛 Keep drop shadows out from under translucent shapes in SVG and PDF

Match the canvas: clip the shape out of its drop shadow on export.

AI-assisted-by: claude-opus-5-5
This commit is contained in:
Elena Torró 2026-11-10 15:05:08 +01:00 committed by GitHub
parent c9d3cbb383
commit 7f0b62b994
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 164 additions and 14 deletions

View File

@ -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();
}

View File

@ -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 `<clipPath>` def.
///
/// Rewrite fill-rule to clip-rule: clipPaths ignore fill-rule, so evenodd

View File

@ -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<Shadow> = 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);

View File

@ -298,6 +298,7 @@ fn render_leaf(
// feMorphology rounding on circles). Filter only blurs/tints.
if !builder.suppress_filters {
let drops: Vec<Shadow> = 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);

View File

@ -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();

View File

@ -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.

View File

@ -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<Item = &Shadow> {
self.shadows

View File

@ -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(

View File

@ -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 {