Export masked groups to WASM SVG as alpha masks

Paint the mask subtree into a native <mask mask-type="alpha"> and wrap
content under mask="url(#…)", matching GPU DstIn (soft/group masks).
Drop silhouettes use the same alpha mask; empty-fill SVG paths inherit
group fills.

Closes #11378
This commit is contained in:
Alejandro Alonso 2026-09-11 14:35:14 +02:00
parent fcae641c7b
commit 056d749cbe
17 changed files with 747 additions and 92 deletions

View File

@ -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<Vec<Fill>>,
}
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<Fill> {
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 `<mask>` bodies
/// from a full mask subtree render.
pub(super) fn capture_body<F>(&mut self, f: F) -> Result<String>
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("<g ");

View File

@ -76,6 +76,21 @@ pub(super) fn add_solid_rect(
);
}
/// Adds a solid-filled ellipse/circle to the pool.
pub(super) fn add_solid_circle(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
color: skia::Color,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Circle);
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
}
/// Adds a rectangle with the given fill stack (bottom → top).
pub(super) fn add_rect_with_fills(
pool: &mut ShapesPool,
@ -201,10 +216,32 @@ pub(super) fn add_group(
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
children: &[Uuid],
) {
add_group_inner(pool, id, parent, (l, t, r, b), children, false);
}
/// Masked group: `children[0]` is the mask, the rest are content (Penpot order).
pub(super) fn add_masked_group(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
children: &[Uuid],
) {
add_group_inner(pool, id, parent, (l, t, r, b), children, true);
}
fn add_group_inner(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
children: &[Uuid],
masked: bool,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Group(Group { masked: false }));
shape.set_shape_type(Type::Group(Group { masked }));
shape.set_selrect(l, t, r, b);
for child in children {
shape.add_child(*child);
@ -347,6 +384,24 @@ pub(super) fn add_stroked_rect(
shape.add_stroke(stroke);
}
/// Closed rectangular path with no fills (inherits parent group fills when
/// nested, matching GPU `nested_fills` for SVG-imported mask groups).
pub(super) fn add_empty_fill_closed_path(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
) {
let segments = vec![
Segment::MoveTo((l, t)),
Segment::LineTo((r, t)),
Segment::LineTo((r, b)),
Segment::LineTo((l, b)),
Segment::Close,
];
add_path_with_fills(pool, id, parent, (l, t, r, b), segments, vec![]);
}
/// Adds a closed rectangular path with a single solid stroke (no fill).
pub(super) fn add_stroked_closed_path(
pool: &mut ShapesPool,

View File

@ -17,6 +17,9 @@ pub(super) fn render_frame(
tree: ShapesPoolRef,
scale: f32,
) -> 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(())
}

View File

@ -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 `<mask>` 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 <mask> (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 `<mask mask-type="alpha">` def.
fn push_alpha_mask(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
mask_id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
) -> Result<String> {
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#"<mask id="{id}" maskUnits="userSpaceOnUse" mask-type="alpha">{body}</mask>"#
));
Ok(id)
}

View File

@ -61,7 +61,7 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
/// `<clipPath>`.
///
/// Layer blur and drop/inner shadows are re-emitted as a native SVG `<filter>`
/// wrapper. Masks still need dedicated SVG re-emission.
/// wrapper. Masked groups use a native alpha `<mask>`.
/// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines;
/// image-filled strokes use a linked `<image>` clipped to the stroke;
/// text strokes use `<g opacity>` / 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<Shadow> = 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 `<g>` —
// 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 <image> + 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(())
}

View File

@ -3,6 +3,8 @@ source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="130.71281" height="110.712814" viewBox="0 0 130.71281 110.712814"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="130.71281" height="110.712814" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="4" dy="6" result="off"/><feGaussianBlur in="off" stdDeviation="5.118802" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="130.71281" height="110.712814" viewBox="0 0 130.71281 110.712814"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="130.71281" height="110.712814" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="5.118802" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<rect fill="red" transform="translate(15.3564 15.3564)" width="100" height="80"/>
</g>
<rect fill="red" transform="translate(11.3564 9.35641)" width="100" height="80"/>
</g></svg>
</svg>

View File

@ -0,0 +1,10 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120" viewBox="0 0 200 120"><defs><mask id="mask0" maskUnits="userSpaceOnUse" mask-type="alpha">
<rect x="40" y="20" width="120" height="80"/>
</mask></defs><g mask="url(#mask0)">
<rect fill="#3D7BFF" width="200" height="120"/>
</g></svg>

View File

@ -0,0 +1,11 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="220" height="100" viewBox="0 0 220 100"><defs><mask id="mask0" maskUnits="userSpaceOnUse" mask-type="alpha">
<rect x="10" y="10" width="40" height="80"/>
<rect fill-opacity="0.50196081" x="70" y="10" width="40" height="80"/>
</mask></defs><g mask="url(#mask0)">
<rect fill="#00C800" width="220" height="100"/>
</g></svg>

View File

@ -3,6 +3,8 @@ source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="241.56406" height="241.56406" viewBox="0 0 241.56406 241.56406"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="241.56406" height="241.56406" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="23.59401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.69803923 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="241.56406" height="241.56406" viewBox="0 0 241.56406 241.56406"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="241.56406" height="241.56406" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="23.59401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.69803923 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#3D7BFF" transform="translate(70.782 70.782)" width="100" height="100"/>
</g></svg>
</g>
<rect fill="#3D7BFF" transform="translate(70.782 70.782)" width="100" height="100"/>
</svg>

View File

@ -3,6 +3,8 @@ source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="110" viewBox="0 0 80 110"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="80" height="110" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="10" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.21960784 0 0 0 0 0 0 0 0 0 0.93333334 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="110" viewBox="0 0 80 110"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="80" height="110" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.21960784 0 0 0 0 0 0 0 0 0 0.93333334 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<rect fill="red" transform="matrix(0 1 -1 0 80 10)" width="100" height="80"/>
</g>
<rect fill="red" transform="matrix(0 1 -1 0 80 0)" width="100" height="80"/>
</g></svg>
</svg>

View File

@ -0,0 +1,10 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="160" height="120" viewBox="0 0 160 120"><defs><mask id="mask0" maskUnits="userSpaceOnUse" mask-type="alpha">
<path transform="translate(-60 -55)" d="M70 65L210 65L210 165L70 165L70 65Z"/>
</mask></defs><g mask="url(#mask0)">
<rect fill="#06D6A0" transform="translate(-60 -55)" x="30" y="40" width="220" height="150"/>
</g></svg>

View File

@ -0,0 +1,14 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="157.64102" height="157.64102" viewBox="0 0 157.64102 157.64102"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="157.64102" height="157.64102" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="6.2735023" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.54901963 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><mask id="mask1" maskUnits="userSpaceOnUse" mask-type="alpha">
<ellipse transform="translate(-61.1795 -41.1795)" cx="140" cy="120" rx="60" ry="60"/>
</mask><mask id="mask2" maskUnits="userSpaceOnUse" mask-type="alpha">
<ellipse transform="translate(-69.1795 -53.1795)" cx="140" cy="120" rx="60" ry="60"/>
</mask></defs><g filter="url(#fx0)"><g mask="url(#mask1)">
<rect fill="#FF006E" transform="translate(-61.1795 -41.1795)" x="40" y="45" width="200" height="140"/>
</g></g><g mask="url(#mask2)">
<rect fill="#FF006E" transform="translate(-69.1795 -53.1795)" x="40" y="45" width="200" height="140"/>
</g></svg>

View File

@ -0,0 +1,12 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="160" height="130" viewBox="0 0 160 130"><defs><mask id="mask0" maskUnits="userSpaceOnUse" mask-type="alpha">
<rect width="160" height="130"/>
</mask><filter id="fx1" filterUnits="userSpaceOnUse" x="0" y="0" width="160" height="130" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g mask="url(#mask0)"><g filter="url(#fx1)">
<ellipse fill="#FFD166" cx="80" cy="70" rx="66" ry="66"/>
</g>
<ellipse fill="#FFD166" cx="80" cy="70" rx="50" ry="50"/>
</g></svg>

View File

@ -3,11 +3,12 @@ source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="280" height="160" viewBox="0 0 280 160"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="280" height="160" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.9843137 0 0 0 0 0.9529412 0 0 0 0 0 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><filter id="fx1" filterUnits="userSpaceOnUse" x="0" y="0" width="280" height="160" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="10" dy="10" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.21960784 0 0 0 0 0 0 0 0 0 0.93333334 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="280" height="160" viewBox="0 0 280 160"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="280" height="160" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.9843137 0 0 0 0 0.9529412 0 0 0 0 0 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><filter id="fx1" filterUnits="userSpaceOnUse" x="0" y="0" width="280" height="160" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="0" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0.21960784 0 0 0 0 0 0 0 0 0 0.93333334 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#EF5350" transform="translate(20 20)" x="40" y="30" width="80" height="40"/>
<path transform="translate(20 20)" d="M260 0L0 0L0 140L260 140L260 0ZM5 5L5 135L255 135L255 5L5 5Z" fill-rule="evenodd"/>
</g><g filter="url(#fx1)">
<rect fill="#EF5350" x="40" y="30" width="80" height="40"/>
<rect fill="#EF5350" transform="translate(10 10)" x="40" y="30" width="80" height="40"/>
</g>
<rect fill="#EF5350" x="40" y="30" width="80" height="40"/>
<path d="M260 0L0 0L0 140L260 140L260 0ZM5 5L5 135L255 135L255 5L5 5Z" fill-rule="evenodd"/>
</svg>

View File

@ -3,6 +3,8 @@ source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="223.7846" height="25.892303" viewBox="0 0 223.7846 25.892303"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="223.7846" height="25.892303" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="12" result="off"/><feGaussianBlur in="off" stdDeviation="3.9641016" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.8 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="223.7846" height="25.892303" viewBox="0 0 223.7846 25.892303"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="223.7846" height="25.892303" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="3.9641016" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.8 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#3D7BFF" transform="translate(11.8923 12)" width="200" height="2"/>
</g>
<rect fill="#3D7BFF" transform="translate(11.8923 0)" width="200" height="2"/>
</g></svg>
</svg>

View File

@ -3,6 +3,8 @@ source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32.856407" height="32.856407" viewBox="0 0 32.856407 32.856407"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="32.856407" height="32.856407" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="4" dy="4" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/><feBlend mode="normal" in="SourceGraphic" in2="drop0" result="shape"/></filter></defs><g filter="url(#fx0)">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32.856407" height="32.856407" viewBox="0 0 32.856407 32.856407"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="32.856407" height="32.856407" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="alpha"/><feOffset in="alpha" dx="0" dy="0" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" result="blurred"/><feColorMatrix in="blurred" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#3D7BFF" transform="translate(8.4282 8.4282)" width="16" height="16"/>
</g>
<rect fill="#3D7BFF" transform="translate(4.4282 4.4282)" width="16" height="16"/>
</g></svg>
</svg>

View File

@ -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("<filter id=\"fx").expect("filter def");
let filter_close = svg[filter_open..]
.find("</filter>")
.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("</g>")
.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("<filter id=\"fx").expect("filter def");
let def_end = svg[def_start..]
.find("</filter>")
.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 <mask>: {svg}"
);
assert!(
svg.contains("mask=\"url(#mask"),
"content must reference the alpha mask: {svg}"
);
// Mask body lives in <defs>; content fill appears under the mask group.
let defs_end = svg.find("</defs>").expect("defs");
let mask_def = svg[..defs_end]
.find("<mask ")
.map(|i| &svg[i..defs_end])
.expect("mask def");
assert!(
mask_def.contains(r#"width="120""#) && mask_def.contains(r#"height="80""#),
"mask def must paint the mask shape geometry: {mask_def}"
);
let body = &svg[defs_end..];
assert!(
body.contains("fill=\"#3D7BFF\"") || body.contains("fill=\"#3d7bff\""),
"content fill must appear in the body: {body}"
);
// Mask geometry must not also paint as a sibling outside the mask wrapper.
let mask_wrapper = body.find("mask=\"url(#mask").expect("mask wrapper");
let before_masked_content = &body[..mask_wrapper];
assert!(
!before_masked_content.contains(r#"width="120""#)
|| !before_masked_content.contains(r#"height="80""#),
"mask shape must not paint as unmasked content: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn masked_leaf_drop_spread_uses_geometric_silhouette() {
// Case 11 style: masked group + circle content with drop spread 16 blur 0.
// GPU paints geometric outset (not feMorphology); the ring must stay circular.
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, 160.0, 130.0),
&[mask_id, content_id],
);
add_solid_rect(
&mut pool,
mask_id,
group_id,
(0.0, 0.0, 160.0, 130.0),
skia::Color::BLACK,
);
add_solid_circle(
&mut pool,
content_id,
group_id,
(30.0, 20.0, 130.0, 120.0),
skia::Color::from_rgb(255, 209, 102),
);
{
let content = pool.get_mut(&content_id).unwrap();
content.add_shadow(Shadow::new(
skia::Color::from_rgb(229, 16, 35),
0.0,
16.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, group_id);
assert!(
svg.contains("mask=\"url(#mask"),
"masked group must wrap content: {svg}"
);
assert!(
!svg.contains("feMorphology"),
"leaf drop spread must not use feMorphology: {svg}"
);
// 100×100 circle + 2×16 spread → 132×132 silhouette ellipse.
let filter_open = svg.find("filter=\"url(#fx").expect("drop filter");
let filter_close = svg[filter_open..]
.find("</g>")
.map(|i| filter_open + i)
.expect("silhouette group close");
let silhouette = &svg[filter_open..=filter_close];
assert!(
silhouette.contains("<ellipse")
&& (silhouette.contains(r#"rx="66""#) || silhouette.contains(r#"rx=\"66\""#)),
"drop silhouette must be a geometrically outset ellipse (rx=66): {silhouette}"
);
// True-size content ellipse remains under the mask.
assert!(
svg.contains(r#"rx="50""#) || svg.matches("rx=\"50\"").count() >= 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("</g>")
.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("</defs>").expect("defs");
let defs = &svg[..defs_end];
assert!(
defs.contains("<ellipse") && (defs.contains(r#"rx="60""#) || defs.contains("rx=\"60\"")),
"mask def must include the circle mask geometry: {defs}"
);
// Geometric offset: silhouette rect translate differs from content by (8,12).
assert!(
silhouette.contains("<rect"),
"silhouette still paints content geometry under the mask: {silhouette}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn mask_group_nested_fills_paint_empty_path() {
// SVG-imported mask group: resolve-shape-fills gives the group a black
// fill; the path child has empty fills and must inherit via nested_fills
// (GPU parity). Without inheritance the <mask> 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("</defs>").expect("defs");
let mask_def = &svg[svg.find("<mask ").expect("mask")..defs_end];
assert!(
!mask_def.contains("<mask") || mask_def.matches("<mask").count() == 1,
"mask def present: {mask_def}"
);
// Closing `</mask>` with no children means inheritance failed.
assert!(
!mask_def.contains(
"<mask id=\"mask0\" maskUnits=\"userSpaceOnUse\" mask-type=\"alpha\"></mask>"
) && (mask_def.contains("<path") || mask_def.contains("<rect")),
"mask must paint inherited-fill path geometry, not be empty: {mask_def}\nfull: {svg}"
);
assert!(
svg.contains("mask=\"url(#mask"),
"content must reference the mask: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_masked_group_when_mask_is_itself_a_group() {
// Issue #11378: mask subtree can be a group of shapes.
let mut pool = ShapesPool::new();
let group_id = uid(1);
let mask_group = uid(2);
let mask_a = uid(3);
let mask_b = uid(4);
let content_id = uid(5);
add_masked_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 220.0, 100.0),
&[mask_group, content_id],
);
add_group(
&mut pool,
mask_group,
group_id,
(10.0, 10.0, 110.0, 90.0),
&[mask_a, mask_b],
);
add_solid_rect(
&mut pool,
mask_a,
mask_group,
(10.0, 10.0, 50.0, 90.0),
skia::Color::BLACK,
);
add_solid_rect(
&mut pool,
mask_b,
mask_group,
(70.0, 10.0, 110.0, 90.0),
skia::Color::from_argb(128, 0, 0, 0),
);
add_solid_rect(
&mut pool,
content_id,
group_id,
(0.0, 0.0, 220.0, 100.0),
skia::Color::from_rgb(0, 200, 0),
);
let svg = render(&pool, group_id);
assert!(
svg.contains("mask-type=\"alpha\"") && svg.contains("mask=\"url(#mask"),
"nested group mask must still emit alpha <mask>: {svg}"
);
let defs_end = svg.find("</defs>").expect("defs");
let mask_def = &svg[svg.find("<mask ").expect("mask")..defs_end];
assert!(
mask_def.matches("<rect").count() >= 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>: {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.