diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index ca051f7e09..8d2cbb059d 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -1,6 +1,7 @@ use skia_safe::{self as skia, Paint}; -use crate::shapes::{radius_to_sigma, Shadow, Shape, Type}; +use crate::error::Result; +use crate::shapes::{radius_to_sigma, Fill, Shadow, Shape, Type}; use crate::state::ShapesPoolRef; use crate::render::vector::draw_shape_geometry; @@ -36,6 +37,10 @@ pub(crate) struct SvgLayerCanvas { /// container silhouette (GPU `pre_translate` before rotation). The SVG /// filter itself uses a zero offset so rotated shadows stay correct. pub(super) silhouette_offset: (f32, f32), + /// Stack of group fills inherited by empty-fill children (GPU `nested_fills`). + /// Frames push an empty vec to break inheritance. SVG-imported groups often + /// carry a default black fill that paths without own fills must paint. + pub(super) nested_fills: Vec>, } impl SvgLayerCanvas { @@ -53,9 +58,30 @@ impl SvgLayerCanvas { suppress_filters: false, silhouette_spread: 0.0, silhouette_offset: (0.0, 0.0), + nested_fills: Vec::new(), } } + /// Fills to paint for a leaf: own fills, else inherited group fills (unless + /// `fill="none"` broke the SVG inheritance chain). Mirrors GPU nested_fills. + /// Returns an owned vec so callers can still mutably borrow `self` afterward. + pub(super) fn effective_fills_owned(&self, element: &Shape) -> Vec { + if !element.fills.is_empty() { + return element.fills.clone(); + } + if matches!(element.shape_type, Type::Group(_) | Type::Frame(_)) { + return Vec::new(); + } + if element + .svg_attrs + .as_ref() + .is_some_and(|attrs| attrs.fill_none) + { + return Vec::new(); + } + self.nested_fills.last().cloned().unwrap_or_default() + } + /// CTM for silhouette geometry: original centered transform, then local /// drop offset (spread is applied by outsetting selrect separately). pub(super) fn silhouette_draw_matrix(&self, element: &Shape) -> skia::Matrix { @@ -110,6 +136,24 @@ impl SvgLayerCanvas { .push_str(&sanitize_skia_svg_fragment(&remap_ids(inner, &prefix))); } + /// Runs `f` while diverting body markup into a temporary buffer. + /// + /// Pending Skia fragments are flushed before/after. Defs (filters, clips, + /// nested masks) still append to `self.defs`. Used to build `` bodies + /// from a full mask subtree render. + pub(super) fn capture_body(&mut self, f: F) -> Result + where + F: FnOnce(&mut Self) -> Result<()>, + { + self.flush(); + let saved = std::mem::take(&mut self.out); + let result = f(self); + self.flush(); + let captured = std::mem::replace(&mut self.out, saved); + result?; + Ok(captured) + } + pub(super) fn open_group(&mut self, attrs: &str) { self.flush(); self.out.push_str(" Result<()> { + // Frames break SVG fill inheritance (GPU pushes an empty nested_fills entry). + builder.nested_fills.push(Vec::new()); + // Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer // before the shadow composite). let composite = opacity_blend_attrs(element); @@ -61,6 +64,7 @@ pub(super) fn render_frame( if composite.is_some() { builder.close_group(); } + builder.nested_fills.pop(); Ok(()) } diff --git a/render-wasm/src/render/svg/groups.rs b/render-wasm/src/render/svg/groups.rs index 9ce454c122..a57e3dae85 100644 --- a/render-wasm/src/render/svg/groups.rs +++ b/render-wasm/src/render/svg/groups.rs @@ -1,6 +1,7 @@ use crate::error::Result; -use crate::shapes::{Shadow, Shape}; +use crate::shapes::{Shadow, Shape, Type}; use crate::state::ShapesPoolRef; +use crate::uuid::Uuid; use super::document::{ content_effect_attrs, opacity_blend_attrs, push_container_drop_filter, SvgLayerCanvas, @@ -15,6 +16,9 @@ pub(super) fn render_group( tree: ShapesPoolRef, scale: f32, ) -> Result<()> { + // Group fills inherit to empty-fill children (GPU nested_fills / SVG fill). + builder.nested_fills.push(element.fills.clone()); + // Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer // before the shadow composite). let composite = opacity_blend_attrs(element); @@ -33,7 +37,10 @@ pub(super) fn render_group( builder.suppress_filters = true; builder.silhouette_spread = shadow.spread; builder.silhouette_offset = shadow.offset; - render_group_children(builder, shared, element, tree, scale)?; + // Masked groups: silhouette must use the same alpha mask as content + // so the drop matches the visible (masked) shape, not raw children. + // silhouette_offset also shifts mask geometry via push_alpha_mask. + render_group_content(builder, shared, element, tree, scale)?; builder.silhouette_offset = prev_offset; builder.silhouette_spread = prev_spread; builder.suppress_filters = prev_suppress; @@ -46,11 +53,7 @@ pub(super) fn render_group( builder.open_group(attrs); } - // Masked groups are deferred: they need an alpha `` compositor that - // will land in a later PR. For now we still emit the full child list - // (including the mask shape as normal content) so basic group opacity - // keeps working. - render_group_children(builder, shared, element, tree, scale)?; + render_group_content(builder, shared, element, tree, scale)?; if effects.is_some() { builder.close_group(); @@ -58,19 +61,62 @@ pub(super) fn render_group( if composite.is_some() { builder.close_group(); } + builder.nested_fills.pop(); Ok(()) } -fn render_group_children( +fn render_group_content( builder: &mut SvgLayerCanvas, shared: &mut RenderResources, element: &Shape, tree: ShapesPoolRef, scale: f32, ) -> Result<()> { + let masked = matches!(element.shape_type, Type::Group(g) if g.masked); + if !masked { + return render_group_content_children(builder, shared, element, tree, scale); + } + + let Some(mask_id) = element.mask_id().copied() else { + return render_group_content_children(builder, shared, element, tree, scale); + }; + + // Paint the mask subtree into an alpha (GPU DstIn uses rendered + // alpha — geometry, fills, soft opacity — not a bare clip outline). + let mask_elem_id = push_alpha_mask(builder, shared, &mask_id, tree, scale)?; + builder.open_group(&format!("mask=\"url(#{mask_elem_id})\"")); + render_group_content_children(builder, shared, element, tree, scale)?; + builder.close_group(); + Ok(()) +} + +fn render_group_content_children( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + // Masked groups: skip the first child (the mask); content only. let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect(); for child_id in &children { render_tree(builder, shared, child_id, tree, scale)?; } Ok(()) } + +/// Renders `mask_id`'s subtree into a `` def. +fn push_alpha_mask( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + mask_id: &Uuid, + tree: ShapesPoolRef, + scale: f32, +) -> Result { + let id = builder.unique("mask"); + let body = builder.capture_body(|b| render_tree(b, shared, mask_id, tree, scale))?; + builder.defs.push_str(&format!( + r#"{body}"# + )); + Ok(id) +} diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index 292ac61461..590a66b3ac 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -61,7 +61,7 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect /// ``. /// /// Layer blur and drop/inner shadows are re-emitted as a native SVG `` -/// wrapper. Masks still need dedicated SVG re-emission. +/// wrapper. Masked groups use a native alpha ``. /// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines; /// image-filled strokes use a linked `` clipped to the stroke; /// text strokes use `` / glyph clipPath / inverse glyph mask, and @@ -137,7 +137,11 @@ use frames::render_frame; use groups::render_group; use text::{render_text_fill, render_text_strokes}; -use document::{effect_attrs, push_text_silhouette_spread_filter, shape_with_selrect_outset}; +use crate::shapes::Shadow; +use document::{ + content_effect_attrs, effect_attrs, opacity_blend_attrs, push_container_drop_filter, + push_text_silhouette_spread_filter, shape_with_selrect_outset, +}; use images::{emit_fills, emit_strokes}; /// Renders `id`'s subtree to an SVG body, returning `(defs, body)`. @@ -188,73 +192,133 @@ fn render_leaf( element: &Shape, tree: ShapesPoolRef, scale: f32, +) -> Result<()> { + // Text keeps the classic leaf filter graph (GPU dilates glyph alpha). Geometry + // leaves match container drops: geometric offset+spread silhouette, then content. + if matches!(element.shape_type, Type::Text(_)) { + return render_leaf_text(builder, shared, element, scale); + } + + let composite = opacity_blend_attrs(element); + if let Some(attrs) = &composite { + builder.open_group(attrs); + } + + // Own drop silhouettes: GPU paints geometric spread/offset (avoids + // feMorphology rounding on circles). Filter only blurs/tints. + if !builder.suppress_filters { + let drops: Vec = element.drop_shadows_visible().copied().collect(); + for shadow in &drops { + let id = push_container_drop_filter(builder, shadow); + builder.open_group(&format!("filter=\"url(#{id})\"")); + let prev_suppress = builder.suppress_filters; + let prev_spread = builder.silhouette_spread; + let prev_offset = builder.silhouette_offset; + builder.suppress_filters = true; + builder.silhouette_spread = shadow.spread; + builder.silhouette_offset = shadow.offset; + render_leaf_geometry(builder, shared, element, tree, scale)?; + builder.silhouette_offset = prev_offset; + builder.silhouette_spread = prev_spread; + builder.suppress_filters = prev_suppress; + builder.close_group(); + } + } + + let effects = content_effect_attrs(builder, element); + if let Some(attrs) = &effects { + builder.open_group(attrs); + } + render_leaf_geometry(builder, shared, element, tree, scale)?; + if effects.is_some() { + builder.close_group(); + } + + if composite.is_some() { + builder.close_group(); + } + Ok(()) +} + +fn render_leaf_text( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + scale: f32, ) -> Result<()> { let effects = effect_attrs(builder, element); if let Some(attrs) = &effects { builder.open_group(attrs); } - { - let spread = builder.silhouette_spread; - // Spread outsets fills only (GPU). Rect/Frame strokes ignore outset. - // Text keeps its selrect: GPU dilates shadow alpha, not layout bounds. - let fill_shape = shape_with_selrect_outset(element, spread); - // Always from the original element (not outset selrect) so the pivot - // matches content; offset comes from the parent silhouette pass. - let draw_matrix = builder.silhouette_draw_matrix(element); - if matches!(element.shape_type, Type::Text(_)) { - // See `push_text_silhouette_spread_filter`: morph-before-blur approx - // of GPU dilate(drop_shadow) for inherited container spread. - let morph_id = push_text_silhouette_spread_filter(builder, spread); - if let Some(id) = &morph_id { - builder.open_group(&format!("filter=\"url(#{id})\"")); - } - render_text_fill(builder, shared, element, draw_matrix)?; - render_text_strokes(builder, shared, element, draw_matrix)?; - if morph_id.is_some() { - builder.close_group(); - } - } else if matches!(element.shape_type, Type::SVGRaw(_)) { - let canvas = builder.canvas(); - canvas.save(); - canvas.concat(&draw_matrix); - let mut renderer = VectorRenderer::new(canvas, shared, scale, false); - renderer.draw_svg(element)?; - canvas.restore(); - } else { - emit_fills( - builder, - shared, - &fill_shape, - &fill_shape.fills, - tree, - scale, - Some(draw_matrix), - )?; - - // Drop/inner shadows are native SVG filters on the effects `` — - // do not draw them via Skia image-filters (SkSVGDevice drops them). - - // Stroke geometry stays on the original selrect (GPU Rect/Frame - // drop-shadow outset is a no-op for single strokes). Image strokes - // go through emit_strokes (linked + stroke clip). - let visible_strokes: Vec<_> = element.visible_strokes().collect(); - if !visible_strokes.is_empty() { - emit_strokes( - builder, - shared, - element, - &visible_strokes, - scale, - Some(draw_matrix), - )?; - } - } + let spread = builder.silhouette_spread; + let draw_matrix = builder.silhouette_draw_matrix(element); + // See `push_text_silhouette_spread_filter`: morph-before-blur approx of + // GPU dilate(drop_shadow) for inherited container spread. + let morph_id = push_text_silhouette_spread_filter(builder, spread); + if let Some(id) = &morph_id { + builder.open_group(&format!("filter=\"url(#{id})\"")); + } + render_text_fill(builder, shared, element, draw_matrix)?; + render_text_strokes(builder, shared, element, draw_matrix)?; + if morph_id.is_some() { + builder.close_group(); } if effects.is_some() { builder.close_group(); } + let _ = scale; + Ok(()) +} + +fn render_leaf_geometry( + builder: &mut SvgLayerCanvas, + shared: &mut RenderResources, + element: &Shape, + tree: ShapesPoolRef, + scale: f32, +) -> Result<()> { + let spread = builder.silhouette_spread; + // Spread outsets fills only (GPU). Rect/Frame strokes ignore outset. + let fill_shape = shape_with_selrect_outset(element, spread); + // Always from the original element (not outset selrect) so the pivot + // matches content; offset comes from the silhouette pass. + let draw_matrix = builder.silhouette_draw_matrix(element); + if matches!(element.shape_type, Type::SVGRaw(_)) { + let canvas = builder.canvas(); + canvas.save(); + canvas.concat(&draw_matrix); + let mut renderer = VectorRenderer::new(canvas, shared, scale, false); + renderer.draw_svg(element)?; + canvas.restore(); + return Ok(()); + } + + let fills = builder.effective_fills_owned(&fill_shape); + emit_fills( + builder, + shared, + &fill_shape, + &fills, + tree, + scale, + Some(draw_matrix), + )?; + + // Stroke geometry stays on the original selrect (GPU Rect/Frame + // drop-shadow outset is a no-op for single strokes). + let visible_strokes: Vec<_> = element.visible_strokes().collect(); + if !visible_strokes.is_empty() { + emit_strokes( + builder, + shared, + element, + &visible_strokes, + scale, + Some(draw_matrix), + )?; + } Ok(()) } diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_drop_shadow_as_svg_filter.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_drop_shadow_as_svg_filter.snap index ef061a4ca1..232b188182 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_drop_shadow_as_svg_filter.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_drop_shadow_as_svg_filter.snap @@ -3,6 +3,8 @@ source: src/render/svg/tests.rs expression: svg --- - + + + - + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_masked_group_as_alpha_mask.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_masked_group_as_alpha_mask.snap new file mode 100644 index 0000000000..53c255ed6d --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_masked_group_as_alpha_mask.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_masked_group_when_mask_is_itself_a_group.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_masked_group_when_mask_is_itself_a_group.snap new file mode 100644 index 0000000000..a8ebfdacf5 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_masked_group_when_mask_is_itself_a_group.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap index 793d98f3ca..a4f1f74f3a 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap @@ -3,6 +3,8 @@ source: src/render/svg/tests.rs expression: svg --- - + - + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__leaf_drop_offset_follows_rotation_in_user_space.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__leaf_drop_offset_follows_rotation_in_user_space.snap index 644760473e..3894e39f37 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__leaf_drop_offset_follows_rotation_in_user_space.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__leaf_drop_offset_follows_rotation_in_user_space.snap @@ -3,6 +3,8 @@ source: src/render/svg/tests.rs expression: svg --- - + + + - + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__mask_group_nested_fills_paint_empty_path.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__mask_group_nested_fills_paint_empty_path.snap new file mode 100644 index 0000000000..fa73f78a4b --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__mask_group_nested_fills_paint_empty_path.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__masked_group_drop_silhouette_is_masked.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__masked_group_drop_silhouette_is_masked.snap new file mode 100644 index 0000000000..f469bf5ff7 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__masked_group_drop_silhouette_is_masked.snap @@ -0,0 +1,14 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__masked_leaf_drop_spread_uses_geometric_silhouette.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__masked_leaf_drop_spread_uses_geometric_silhouette.snap new file mode 100644 index 0000000000..3a78b9ef21 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__masked_leaf_drop_spread_uses_geometric_silhouette.snap @@ -0,0 +1,12 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_child_drop_shadow_is_not_refiltered_by_frame.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_child_drop_shadow_is_not_refiltered_by_frame.snap index 70bcafea7f..a89784cc02 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_child_drop_shadow_is_not_refiltered_by_frame.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__nested_child_drop_shadow_is_not_refiltered_by_frame.snap @@ -3,11 +3,12 @@ source: src/render/svg/tests.rs expression: svg --- - + - + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap index ed1a8b3558..f558b442c6 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap @@ -3,6 +3,8 @@ source: src/render/svg/tests.rs expression: svg --- - + + + - + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__small_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__small_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap index 6f867f5750..baa7398504 100644 --- a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__small_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__small_leaf_drop_shadow_filter_covers_page_not_bbox_percent.snap @@ -3,6 +3,8 @@ 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 1ecdc699c0..3c90779d20 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -219,17 +219,38 @@ fn exports_leaf_drop_shadow_as_svg_filter() { let svg = render(&pool, id); let expected_sigma = radius_to_sigma(8.0); + // Geometry leaves: offset is geometric (silhouette), filter only blurs/tints + // (matches GPU — no feMorphology, no SourceGraphic in the drop filter). + // Page padding shifts both passes; silhouette is padding+(4,6). assert!( - svg.contains("feOffset") && svg.contains(r#"dx="4""#) && svg.contains(r#"dy="6""#), - "drop shadow must offset: {svg}" + svg.contains(r#"translate(15.3564 15.3564)"#), + "drop silhouette must include geometric offset (4,6) over page pad: {svg}" + ); + assert!( + svg.contains(r#"translate(11.3564 9.35641)"#), + "content pass must stay at page pad without drop offset: {svg}" ); assert!( svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")), "drop blur sigma must match canvas: {svg}" ); assert!( - svg.contains("filter=\"url(#fx") && svg.contains("SourceGraphic"), - "drop shadow filter must blend SourceGraphic: {svg}" + svg.contains("filter=\"url(#fx"), + "drop shadow must emit a filter: {svg}" + ); + let filter_open = svg.find("") + .map(|i| filter_open + i) + .expect("filter close"); + let filter = &svg[filter_open..filter_close]; + assert!( + !filter.contains("SourceGraphic"), + "leaf drop silhouette filter must not blend SourceGraphic: {filter}" + ); + assert!( + !filter.contains("feMorphology"), + "leaf drop must not use feMorphology for spread: {filter}" ); insta::assert_snapshot!(svg); } @@ -262,13 +283,31 @@ fn leaf_drop_offset_follows_rotation_in_user_space() { } let svg = render(&pool, id); + // Geometric silhouette: local offset is pre_translated before rotation. + let filter_open = svg.find("filter=\"url(#fx").expect("drop filter"); + let filter_close = svg[filter_open..] + .find("") + .map(|i| filter_open + i) + .expect("silhouette group close"); + let silhouette = &svg[filter_open..=filter_close]; assert!( - svg.contains(r#"dx="0""#) && svg.contains(r#"dy="10""#), - "rotated leaf drop must map local offset into filter user space: {svg}" + silhouette.contains("matrix(") || silhouette.contains("translate("), + "rotated leaf drop silhouette must carry a CTM: {silhouette}" + ); + // Filter itself keeps zero offset (geometry moved instead). + let def_start = svg.find("") + .map(|i| def_start + i) + .expect("filter close"); + let filter = &svg[def_start..def_end]; + assert!( + filter.contains(r#"dx="0""#) && filter.contains(r#"dy="0""#), + "rotated leaf drop filter offset must stay zero: {filter}" ); assert!( - !svg.contains(r#"dx="10""#), - "must not keep unmapped local dx for rotated leaf: {svg}" + !filter.contains(r#"dx="10""#), + "must not keep unmapped local dx in the filter: {filter}" ); insta::assert_snapshot!(svg); } @@ -366,9 +405,10 @@ fn sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent() { let svg = render(&pool, id); assert_filter_covers_page(&svg); + // Geometric offset (0,12): silhouette y = page_pad_y + 12, content y = page_pad_y. assert!( - svg.contains(r#"dy="12""#), - "sliver drop must keep its offset: {svg}" + svg.contains(r#"translate(11.8923 12)"#) && svg.contains(r#"translate(11.8923 0)"#), + "sliver drop must apply geometric offset (0,12): {svg}" ); insta::assert_snapshot!(svg); } @@ -911,9 +951,10 @@ fn nested_child_drop_shadow_is_not_refiltered_by_frame() { child_filter > frame_filter_close, "child drop filter must be outside frame drop group to avoid shadow-of-shadow: {svg}" ); + // Child leaf drop offset is geometric (translate), not feOffset. assert!( - svg.contains(r#"dx="10""#), - "child leaf drop must keep filter offset: {svg}" + svg.contains(r#"translate(10 10)"#), + "child leaf drop must keep geometric offset: {svg}" ); // Frame container drops apply offset geometrically (filter dx=0). assert!( @@ -1118,6 +1159,339 @@ fn exports_a_group_with_two_rects_and_group_opacity() { insta::assert_snapshot!(svg); } +#[test] +fn exports_masked_group_as_alpha_mask() { + // First child is the mask; content must be under mask="url(#…)" and the + // mask shape must not appear as unmasked sibling content (Closes #11378). + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let mask_id = uid(2); + let content_id = uid(3); + + add_masked_group( + &mut pool, + group_id, + Uuid::nil(), + (0.0, 0.0, 200.0, 120.0), + &[mask_id, content_id], + ); + add_solid_rect( + &mut pool, + mask_id, + group_id, + (40.0, 20.0, 160.0, 100.0), + skia::Color::BLACK, + ); + add_solid_rect( + &mut pool, + content_id, + group_id, + (0.0, 0.0, 200.0, 120.0), + skia::Color::from_rgb(61, 123, 255), + ); + + let svg = render(&pool, group_id); + assert!( + svg.contains("mask-type=\"alpha\"") && svg.contains("maskUnits=\"userSpaceOnUse\""), + "masked group must emit an alpha : {svg}" + ); + assert!( + svg.contains("mask=\"url(#mask"), + "content must reference the alpha mask: {svg}" + ); + + // Mask body lives in ; content fill appears under the mask group. + let defs_end = svg.find("").expect("defs"); + let mask_def = svg[..defs_end] + .find("") + .map(|i| filter_open + i) + .expect("silhouette group close"); + let silhouette = &svg[filter_open..=filter_close]; + assert!( + silhouette.contains("= 1, + "content ellipse must keep true size rx=50: {svg}" + ); + insta::assert_snapshot!(svg); +} + +#[test] +fn masked_group_drop_silhouette_is_masked() { + // Circle mask + oversized rect content + group drop: silhouette must be + // masked (circular), not the raw rect. Offset stays geometric. + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let mask_id = uid(2); + let content_id = uid(3); + + add_masked_group( + &mut pool, + group_id, + Uuid::nil(), + (80.0, 60.0, 200.0, 180.0), + &[mask_id, content_id], + ); + add_solid_circle( + &mut pool, + mask_id, + group_id, + (80.0, 60.0, 200.0, 180.0), + skia::Color::BLACK, + ); + add_solid_rect( + &mut pool, + content_id, + group_id, + (40.0, 45.0, 240.0, 185.0), + skia::Color::from_rgb(255, 0, 110), + ); + { + let group = pool.get_mut(&group_id).unwrap(); + group.add_shadow(Shadow::new( + skia::Color::from_argb(140, 0, 0, 0), + 10.0, + 0.0, + (8.0, 12.0), + ShadowStyle::Drop, + false, + )); + } + + let svg = render(&pool, group_id); + let filter_open = svg.find("filter=\"url(#fx").expect("drop filter"); + let filter_close = svg[filter_open..] + .find("") + .map(|i| filter_open + i) + .expect("silhouette group close"); + let silhouette = &svg[filter_open..=filter_close]; + assert!( + silhouette.contains("mask=\"url(#mask"), + "group drop silhouette must be masked to the mask shape: {silhouette}\nfull: {svg}" + ); + // Silhouette mask body is a circle (ellipse), not only the rect content. + let defs_end = svg.find("").expect("defs"); + let defs = &svg[..defs_end]; + assert!( + defs.contains(" body is empty. + let mut pool = ShapesPool::new(); + let group_id = uid(1); + let mask_group = uid(2); + let path_id = uid(3); + let content_id = uid(4); + + add_masked_group( + &mut pool, + group_id, + Uuid::nil(), + (60.0, 55.0, 220.0, 175.0), + &[mask_group, content_id], + ); + add_group( + &mut pool, + mask_group, + group_id, + (60.0, 55.0, 220.0, 175.0), + &[path_id], + ); + { + let g = pool.get_mut(&mask_group).unwrap(); + // Mirrors apply-svg-derived / resolve-shape-fills for groups with :svg-attrs. + g.set_fills(vec![Fill::Solid(SolidColor(skia::Color::BLACK))]); + g.svg_attrs = Some(crate::shapes::SvgAttrs::default()); + } + add_empty_fill_closed_path(&mut pool, path_id, mask_group, (70.0, 65.0, 210.0, 165.0)); + add_solid_rect( + &mut pool, + content_id, + group_id, + (30.0, 40.0, 250.0, 190.0), + skia::Color::from_rgb(6, 214, 160), + ); + + let svg = render(&pool, group_id); + let defs_end = svg.find("").expect("defs"); + let mask_def = &svg[svg.find("` with no children means inheritance failed. + assert!( + !mask_def.contains( + "" + ) && (mask_def.contains(": {svg}" + ); + let defs_end = svg.find("").expect("defs"); + let mask_def = &svg[svg.find("= 2, + "mask group must paint both children into the mask: {mask_def}" + ); + assert!( + mask_def.contains("fill-opacity"), + "soft mask child alpha must reach the : {mask_def}" + ); + insta::assert_snapshot!(svg); +} + #[test] fn loads_svg_raw_dom_like_wasm_upload() { // Production paints svg-raw via Dom::render after set_shape_svg_raw_content.