mirror of
https://github.com/penpot/penpot.git
synced 2026-09-22 03:46:16 +00:00
✨ 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:
parent
fcae641c7b
commit
056d749cbe
@ -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 ");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user