From fcae641c7bc71b620327ad5a3cc875e66ec0c394 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 16 Sep 2026 16:08:17 +0200 Subject: [PATCH] :bug: Skip content clip on WASM SVG drop silhouettes (#11655) Clipped frames (show-content=false) were applying clipPath to the drop silhouette. Spread/offset fills were outset correctly but then truncated to the true selrect, so spread rings vanished on export. Match the GPU: paint drop silhouettes outside the content clip; keep clipping on the real content pass only. Closes #11653 --- render-wasm/src/render/svg/frames.rs | 5 +- ...shadow_silhouette_skips_content_clip.snap} | 6 +- ...read_is_not_truncated_by_content_clip.snap | 14 +++ render-wasm/src/render/svg/tests.rs | 105 +++++++++++++----- 4 files changed, 95 insertions(+), 35 deletions(-) rename render-wasm/src/render/svg/snapshots/{render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap => render_wasm__render__svg__tests__clipped_frame_drop_shadow_silhouette_skips_content_clip.snap} (83%) create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_spread_is_not_truncated_by_content_clip.snap diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs index 1a896fc25b..28574ebbfc 100644 --- a/render-wasm/src/render/svg/frames.rs +++ b/render-wasm/src/render/svg/frames.rs @@ -72,7 +72,10 @@ fn render_frame_body( scale: f32, ) -> Result<()> { let spread = builder.silhouette_spread; - let clipped = element.clip_content; + // Clip only on the real content pass. Drop silhouettes are painted outside + // the content clip (GPU), so spread/offset rings are not truncated by an + // un-outset / unshifted clipPath. + let clipped = element.clip_content && !builder.suppress_filters; if clipped { let clip_id = builder.unique("clip"); builder.push_clip_path(&clip_id, element, tree); diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_silhouette_skips_content_clip.snap similarity index 83% rename from render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap rename to render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_silhouette_skips_content_clip.snap index 9b8a4c38bf..1d6d089d32 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_clip_follows_silhouette_offset.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_shadow_silhouette_skips_content_clip.snap @@ -4,13 +4,11 @@ expression: svg --- - - - + - + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_spread_is_not_truncated_by_content_clip.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_spread_is_not_truncated_by_content_clip.snap new file mode 100644 index 0000000000..b46d121b1b --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__clipped_frame_drop_spread_is_not_truncated_by_content_clip.snap @@ -0,0 +1,14 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + + + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index cae1ccfefa..1ecdc699c0 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -500,10 +500,10 @@ fn exports_frame_drop_shadow_wrapping_children() { } #[test] -fn clipped_frame_drop_shadow_clip_follows_silhouette_offset() { - // clip=ON + drop offset: silhouette fills/children move with - // silhouette_draw_matrix, so the board clipPath must move too — otherwise - // the unshifted clip truncates the shadow (F1a / show-content=false). +fn clipped_frame_drop_shadow_silhouette_skips_content_clip() { + // clip=ON + drop offset: GPU paints the drop outside the content clip, so + // the silhouette must not use clipPath (F1a). Otherwise an unshifted clip + // truncates the offset shadow. let mut pool = ShapesPool::new(); let frame_id = uid(1); let child = uid(2); @@ -543,10 +543,6 @@ fn clipped_frame_drop_shadow_clip_follows_silhouette_offset() { svg.contains("filter=\"url(#fx"), "clipped frame drop shadow must emit a filter: {svg}" ); - assert!( - svg.matches("= 2, - "silhouette and content each need a clipPath: {svg}" - ); let filter_open = svg.find("filter=\"url(#fx").expect("frame filter"); let filter_close = svg[filter_open..] @@ -554,29 +550,78 @@ fn clipped_frame_drop_shadow_clip_follows_silhouette_offset() { .map(|i| filter_open + i) .expect("silhouette group close"); let silhouette = &svg[filter_open..=filter_close]; - let clip_ref = silhouette - .find("clip-path=\"url(#") - .and_then(|i| { - let start = i + "clip-path=\"url(#".len(); - let end = silhouette[start..].find(')')?; - Some(&silhouette[start..start + end]) - }) - .expect("silhouette must reference a clipPath"); - - let clip_def_start = svg - .find(&format!("") - .map(|i| clip_def_start + i) - .expect("clipPath close"); - let clip_geom = &svg[clip_def_start..clip_def_end]; - - // Content clip (second clipPath) stays unshifted; silhouette clip must - // carry the local drop offset (0, 24) like silhouette fills. assert!( - clip_geom.contains("translate(") && clip_geom.contains(" 24"), - "silhouette clipPath must follow drop offset (0,24): {clip_geom}\nfull: {svg}" + !silhouette.contains("clip-path="), + "drop silhouette must not use content clip: {silhouette}" + ); + assert!( + silhouette.contains(r#"translate(0 24)"#), + "silhouette fills must still apply drop offset: {silhouette}" + ); + assert!( + svg.contains("") + .map(|i| filter_open + i) + .expect("silhouette group close"); + let silhouette = &svg[filter_open..=filter_close]; + assert!( + !silhouette.contains("clip-path="), + "spread silhouette must not be content-clipped: {silhouette}" + ); + // 200×120 + 2×24 spread, and child 160×80 + 2×24. + assert!( + silhouette.contains(r#"width="248""#) && silhouette.contains(r#"height="168""#), + "frame fill must outset by spread 24: {silhouette}" + ); + assert!( + silhouette.contains(r#"width="208""#) && silhouette.contains(r#"height="128""#), + "child fill must outset by inherited spread 24: {silhouette}" ); insta::assert_snapshot!(svg); }