Export drop and inner shadows to WASM SVG (#11593)

SkSVGDevice drops save_layer image-filters, so re-emit visible drop and
inner shadows (plus optional layer blur) as one native SVG filter chain
matching classic order: flood, drops, SourceGraphic, inners, blur.

Closes #11379
This commit is contained in:
Alejandro Alonso 2026-09-11 13:52:57 +02:00 committed by GitHub
parent f3da8af7b6
commit 3adf9ade14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1669 additions and 101 deletions

View File

@ -1,6 +1,6 @@
use skia_safe::{self as skia, Paint};
use crate::shapes::{radius_to_sigma, Shape, Type};
use crate::shapes::{radius_to_sigma, Shadow, Shape, Type};
use crate::state::ShapesPoolRef;
use crate::render::vector::draw_shape_geometry;
@ -25,6 +25,17 @@ pub(crate) struct SvgLayerCanvas {
pending: Option<skia::svg::Canvas>,
next_id: usize,
frag_no: usize,
/// When true, skip SVG `<filter>` effects so a parent drop-shadow pass can
/// sample silhouettes without nested child shadows (shadow-of-shadow).
pub(super) suppress_filters: bool,
/// Design-space outset applied while drawing a container drop-shadow
/// silhouette. Matches GPU geometric spread (avoids `feMorphology` fattening
/// stroke rings on both edges).
pub(super) silhouette_spread: f32,
/// Design-space drop offset applied in local shape space while drawing a
/// 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),
}
impl SvgLayerCanvas {
@ -39,9 +50,23 @@ impl SvgLayerCanvas {
pending: None,
next_id: 0,
frag_no: 0,
suppress_filters: false,
silhouette_spread: 0.0,
silhouette_offset: (0.0, 0.0),
}
}
/// 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 {
let mut matrix = element.centered_transform();
let (dx, dy) = self.silhouette_offset;
if dx != 0.0 || dy != 0.0 {
matrix.pre_translate((dx, dy));
}
matrix
}
pub(super) fn unique(&mut self, prefix: &str) -> String {
let id = format!("{prefix}{}", self.next_id);
self.next_id += 1;
@ -103,11 +128,11 @@ impl SvgLayerCanvas {
self.out.push_str(markup);
}
/// CTM for leaf content placed in page space: Scale * Translate * Centered.
pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String {
/// CTM for leaf content placed in page space: Scale * Translate * `draw_matrix`.
pub(super) fn page_draw_matrix_attr(&self, draw_matrix: &skia::Matrix) -> String {
let mut ctm = skia::Matrix::scale((self.scale, self.scale));
ctm = ctm * skia::Matrix::translate((self.tx, self.ty));
ctm = ctm * shape.centered_transform();
ctm = ctm * *draw_matrix;
format!(
"matrix({} {} {} {} {} {})",
ctm.scale_x(),
@ -123,6 +148,9 @@ impl SvgLayerCanvas {
///
/// A mask can be a group too. Since a group has no geometry of its own, we
/// recurse into its descendants and accumulate their geometry.
///
/// Uses [`Self::silhouette_offset`] so clipped container drop silhouettes
/// move their clip with the offset content (GPU parity).
pub(super) fn push_clip_path(&mut self, id: &str, shape: &Shape, tree: ShapesPoolRef) {
let canvas = self.new_fragment();
{
@ -130,7 +158,7 @@ impl SvgLayerCanvas {
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_color(skia::Color::BLACK);
draw_clip_geometry(cv, shape, tree, &paint);
draw_clip_geometry(cv, shape, tree, &paint, self.silhouette_offset);
}
self.finish_clip_path_fragment(id, canvas);
}
@ -152,49 +180,242 @@ impl SvgLayerCanvas {
));
}
/// Registers a layer-blur `<filter>` and returns its id.
/// Registers a composite effects `<filter>` (drop/inner shadows + optional
/// layer blur) matching classic SVG filter order, and returns its id.
///
/// `sigma` is Skia/canvas stdDeviation (`radius_to_sigma(value * scale)`).
/// Padding (±50%) avoids the default 10% objectBoundingBox clip on large blurs.
pub(super) fn push_layer_blur_filter(&mut self, sigma: f32) -> String {
let id = self.unique("blur");
/// Order: transparent flood → drop shadows → SourceGraphic → inner shadows
/// → layer blur. Shadow blur uses canvas sigma (`radius_to_sigma`); offsets
/// and spread are scaled by the export scale.
///
/// When `blend_source_graphic` is false, the filter ends after the drop
/// chain (used for container silhouette passes that must not re-emit
/// content — content is drawn in a separate unfiltered group).
pub(super) fn push_effects_filter(
&mut self,
drops: &[&Shadow],
inners: &[&Shadow],
layer_blur_sigma: Option<f32>,
scale: f32,
blend_source_graphic: bool,
) -> String {
let id = self.unique("fx");
let mut body = String::new();
body.push_str(r#"<feFlood flood-opacity="0" result="bg"/>"#);
let mut prev = "bg".to_string();
for (i, shadow) in drops.iter().enumerate() {
let result = format!("drop{i}");
append_drop_shadow_primitives(
&mut body, shadow, scale, &prev, &result, /* morph_spread */ true,
);
prev = result;
}
if blend_source_graphic {
body.push_str(&format!(
r#"<feBlend mode="normal" in="SourceGraphic" in2="{prev}" result="shape"/>"#
));
prev = "shape".to_string();
for (i, shadow) in inners.iter().enumerate() {
let result = format!("inner{i}");
append_inner_shadow_primitives(&mut body, shadow, scale, &prev, &result);
prev = result;
}
if let Some(sigma) = layer_blur_sigma {
body.push_str(&format!(
r#"<feGaussianBlur in="{prev}" stdDeviation="{sigma}"/>"#
));
}
}
self.defs.push_str(&format!(
concat!(
"<filter id=\"{id}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" ",
"color-interpolation-filters=\"sRGB\">",
"<feGaussianBlur stdDeviation=\"{sigma}\"/>",
"</filter>"
"<filter id=\"{id}\" {region} color-interpolation-filters=\"sRGB\">",
"{body}</filter>"
),
id = id,
sigma = sigma
region = filter_region_attrs(self),
body = body
));
id
}
}
fn color_matrix_values(color: skia::Color) -> String {
let r = f32::from(color.r()) / 255.0;
let g = f32::from(color.g()) / 255.0;
let b = f32::from(color.b()) / 255.0;
let a = f32::from(color.a()) / 255.0;
format!("0 0 0 0 {r} 0 0 0 0 {g} 0 0 0 0 {b} 0 0 0 {a} 0")
}
/// Filter subregion covering the export page in user space.
///
/// Default SVG `objectBoundingBox` + `x/y=-50% width/height=200%` is a percent
/// of the *shape* bbox. Shadow reach (offset + blur sigma + spread) is absolute
/// pixels, so small/thin shapes crop the halo. Page bounds already include
/// shadow/blur via `extrect`; sizing the filter to the page matches that.
fn filter_region_attrs(builder: &SvgLayerCanvas) -> String {
let w = builder.page_rect.width();
let h = builder.page_rect.height();
format!(r#"filterUnits="userSpaceOnUse" x="0" y="0" width="{w}" height="{h}""#)
}
fn append_drop_shadow_primitives(
body: &mut String,
shadow: &Shadow,
scale: f32,
in2: &str,
result: &str,
morph_spread: bool,
) {
let sigma = radius_to_sigma(shadow.blur * scale);
// `shadow.offset` must already be in the SVG filter user space (parent of
// the transformed leaf). Callers map local design offsets through
// `centered_transform().map_vector` for rotated/flipped leaves.
let dx = shadow.offset.0 * scale;
let dy = shadow.offset.1 * scale;
let spread = shadow.spread * scale;
let color = color_matrix_values(shadow.color);
body.push_str(
r#"<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"/>"#,
);
// Container silhouettes apply spread geometrically (GPU). Morphology on a
// stroke ring expands both edges and makes border shadows look too thick.
let after_morph = if morph_spread && spread > 0.0 {
body.push_str(&format!(
r#"<feMorphology in="alpha" operator="dilate" radius="{spread}" result="spread"/>"#
));
"spread"
} else if morph_spread && spread < 0.0 {
body.push_str(&format!(
r#"<feMorphology in="alpha" operator="erode" radius="{}" result="spread"/>"#,
-spread
));
"spread"
} else {
"alpha"
};
body.push_str(&format!(
r#"<feOffset in="{after_morph}" dx="{dx}" dy="{dy}" result="off"/>"#
));
body.push_str(&format!(
r#"<feGaussianBlur in="off" stdDeviation="{sigma}" result="blurred"/>"#
));
body.push_str(&format!(
r#"<feColorMatrix in="blurred" type="matrix" values="{color}" result="colored"/>"#
));
body.push_str(&format!(
r#"<feBlend mode="normal" in="colored" in2="{in2}" result="{result}"/>"#
));
}
fn append_inner_shadow_primitives(
body: &mut String,
shadow: &Shadow,
scale: f32,
in2: &str,
result: &str,
) {
let sigma = radius_to_sigma(shadow.blur * scale);
let dx = shadow.offset.0 * scale;
let dy = shadow.offset.1 * scale;
let spread = shadow.spread * scale;
let color = color_matrix_values(shadow.color);
// Classic inner-shadow graph: hard alpha, optional erode for +spread,
// offset+blur, subtract from hard alpha, tint, blend over prior result.
body.push_str(
r#"<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="hardAlpha"/>"#,
);
let morph_in = if spread > 0.0 {
body.push_str(&format!(
r#"<feMorphology in="hardAlpha" operator="erode" radius="{spread}" result="spread"/>"#
));
"spread"
} else {
"hardAlpha"
};
body.push_str(&format!(
r#"<feOffset in="{morph_in}" dx="{dx}" dy="{dy}" result="off"/>"#
));
body.push_str(&format!(
r#"<feGaussianBlur in="off" stdDeviation="{sigma}" result="blurred"/>"#
));
body.push_str(
r#"<feComposite in="blurred" in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" result="shadow"/>"#,
);
body.push_str(&format!(
r#"<feColorMatrix in="shadow" type="matrix" values="{color}" result="colored"/>"#
));
body.push_str(&format!(
r#"<feBlend mode="normal" in="colored" in2="{in2}" result="{result}"/>"#
));
}
/// Draws a clip geometry into `cv` (already set up with the page transform).
fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) {
///
/// `silhouette_offset` is applied in local space (same as
/// [`SvgLayerCanvas::silhouette_draw_matrix`]) so board `clip content` during a
/// container drop pass tracks the shifted silhouette.
fn draw_clip_geometry(
cv: &skia::Canvas,
shape: &Shape,
tree: ShapesPoolRef,
paint: &Paint,
silhouette_offset: (f32, f32),
) {
if let Type::Group(_) = &shape.shape_type {
for child_id in shape.children_ids_iter_forward(true) {
if let Some(child) = tree.get(child_id) {
draw_clip_geometry(cv, child, tree, paint);
draw_clip_geometry(cv, child, tree, paint, silhouette_offset);
}
}
return;
}
cv.save();
cv.concat(&shape.centered_transform());
let mut matrix = shape.centered_transform();
let (dx, dy) = silhouette_offset;
if dx != 0.0 || dy != 0.0 {
matrix.pre_translate((dx, dy));
}
cv.concat(&matrix);
draw_shape_geometry(cv, shape, paint);
cv.restore();
}
/// Builds the `<g>` attribute string for a shape's composite effects (opacity,
/// blend mode, layer blur). Returns `None` when the shape needs no wrapper.
/// Returns a shape whose `selrect` is expanded/shrunk by `outset` (design space).
/// Used for GPU-matching geometric drop-shadow spread.
pub(super) fn shape_with_selrect_outset(shape: &Shape, outset: f32) -> Shape {
let mut out = shape.clone();
if outset > 0.0 {
out.selrect.outset((outset, outset));
} else if outset < 0.0 {
out.selrect.inset((-outset, -outset));
}
out
}
/// Builds the `<g>` attribute string for a leaf shape's composite effects
/// (opacity, blend mode, drop/inner shadows, layer blur). Returns `None` when
/// the shape needs no wrapper.
///
/// Layer blur is a native SVG `<filter>` (SkSVGDevice drops paint image-filters).
/// Shadows still need dedicated re-emission in a later PR.
/// Shadows and layer blur are native SVG `<filter>`s — `SkSVGDevice` drops the
/// GPU `save_layer` image-filter path. When `suppress_filters` is set (parent
/// drop-shadow silhouette pass), only opacity/blend are emitted.
pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Option<String> {
wrapper_attrs(builder, element, EffectFilterMode::LeafComposite)
}
/// Opacity / blend for a container, wrapping both drop silhouettes and content.
///
/// Matches GPU `render_shape_enter` (opacity save_layer before shadow composite)
/// so container drop shadows inherit the board's opacity.
pub(super) fn opacity_blend_attrs(element: &Shape) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
let opacity = element.opacity();
@ -206,11 +427,168 @@ pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Opt
parts.push(format!("style=\"mix-blend-mode:{css}\""));
}
if let Some(blur) = element.visible_layer_blur() {
// Match canvas `Shape::image_filter`: sigma from radius × export scale.
let sigma = radius_to_sigma(blur.value * builder.scale);
let id = builder.push_layer_blur_filter(sigma);
parts.push(format!("filter=\"url(#{id})\""));
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
/// Inner shadows / layer blur for a container's real content group.
///
/// Opacity and blend are applied by [`opacity_blend_attrs`] around silhouette +
/// content. Drop shadows are a separate silhouette pass.
pub(super) fn content_effect_attrs(
builder: &mut SvgLayerCanvas,
element: &Shape,
) -> Option<String> {
wrapper_attrs(builder, element, EffectFilterMode::ContentWithoutDrops)
}
/// Drop-shadow-only filter for one container silhouette pass.
///
/// Offset and spread are applied geometrically while drawing (local space,
/// matching GPU). The filter only hardens alpha, blurs, and tints.
pub(super) fn push_container_drop_filter(builder: &mut SvgLayerCanvas, shadow: &Shadow) -> String {
let scale = builder.scale;
let id = builder.unique("fx");
let mut body = String::from(r#"<feFlood flood-opacity="0" result="bg"/>"#);
// Zero offset: geometric `pre_translate` already moved the silhouette.
let mut filter_shadow = *shadow;
filter_shadow.offset = (0.0, 0.0);
append_drop_shadow_primitives(
&mut body,
&filter_shadow,
scale,
"bg",
"drop0",
/* morph_spread */ false,
);
builder.defs.push_str(&format!(
concat!(
"<filter id=\"{id}\" {region} color-interpolation-filters=\"sRGB\">",
"{body}</filter>"
),
id = id,
region = filter_region_attrs(builder),
body = body
));
id
}
/// Dilates/erodes glyph alpha for text drawn inside a container drop silhouette.
///
/// Container drop filters omit `feMorphology` so stroke-ring silhouettes stay
/// thin (morph fattens both edges). Text children still need spread: GPU paints
/// them with `Shadow::get_drop_shadow_filter`, which wraps `drop_shadow_only`
/// in `dilate(spread)` (thicken the already blurred shadow).
///
/// Approximation: we nest `feMorphology` on the glyph alpha *before* the
/// parent container blur. That is morph-then-blur, not dilate-after-drop like
/// Skia. Close enough for export parity; halo softness can differ slightly.
pub(super) fn push_text_silhouette_spread_filter(
builder: &mut SvgLayerCanvas,
spread: f32,
) -> Option<String> {
let radius = spread * builder.scale;
if radius == 0.0 {
return None;
}
let id = builder.unique("txmorph");
let (op, r) = if radius > 0.0 {
("dilate", radius)
} else {
("erode", -radius)
};
builder.defs.push_str(&format!(
concat!(
"<filter id=\"{id}\" {region} color-interpolation-filters=\"sRGB\">",
"<feMorphology in=\"SourceAlpha\" operator=\"{op}\" radius=\"{r}\" result=\"m\"/>",
"<feFlood flood-color=\"#000000\" flood-opacity=\"1\" result=\"f\"/>",
"<feComposite in=\"f\" in2=\"m\" operator=\"in\"/>",
"</filter>"
),
id = id,
region = filter_region_attrs(builder),
op = op,
r = r
));
Some(id)
}
#[derive(Clone, Copy)]
enum EffectFilterMode {
/// Flood → drops → SourceGraphic → inners → blur (leaves).
LeafComposite,
/// Flood → SourceGraphic → inners → blur (container content; drops separate).
ContentWithoutDrops,
}
/// Maps a design-space shadow offset into SVG filter user space.
///
/// Leaf geometry is drawn with `centered_transform` (rotation / flip). GPU
/// `drop_shadow_only` offsets in that local space, then the CTM maps it. SVG
/// `feOffset` runs after the leaf is painted into the parent group, so the
/// offset must be `map_vector` of the local offset or rotated shadows drift.
fn shadow_with_user_space_offset(shape: &Shape, shadow: &Shadow) -> Shadow {
let mut out = *shadow;
let mapped = shape.centered_transform().map_vector(shadow.offset);
out.offset = (mapped.x, mapped.y);
out
}
fn wrapper_attrs(
builder: &mut SvgLayerCanvas,
element: &Shape,
mode: EffectFilterMode,
) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
// Leaves keep opacity/blend on the same wrapper as their filter. Containers
// use [`opacity_blend_attrs`] outside silhouette + content instead.
if matches!(mode, EffectFilterMode::LeafComposite) {
let opacity = element.opacity();
if opacity < 1.0 {
parts.push(format!("opacity=\"{opacity}\""));
}
if let Some(css) = blend_css(element.blend_mode().0) {
parts.push(format!("style=\"mix-blend-mode:{css}\""));
}
}
if !builder.suppress_filters {
let scale = builder.scale;
let mapped_inners: Vec<Shadow> = element
.inner_shadows_visible()
.map(|s| shadow_with_user_space_offset(element, s))
.collect();
let inners: Vec<&Shadow> = mapped_inners.iter().collect();
let layer_blur_sigma = element
.visible_layer_blur()
.map(|blur| radius_to_sigma(blur.value * scale));
match mode {
EffectFilterMode::LeafComposite => {
let mapped_drops: Vec<Shadow> = element
.drop_shadows_visible()
.map(|s| shadow_with_user_space_offset(element, s))
.collect();
let drops: Vec<&Shadow> = mapped_drops.iter().collect();
if !drops.is_empty() || !inners.is_empty() || layer_blur_sigma.is_some() {
let id =
builder.push_effects_filter(&drops, &inners, layer_blur_sigma, scale, true);
parts.push(format!("filter=\"url(#{id})\""));
}
}
EffectFilterMode::ContentWithoutDrops => {
if !inners.is_empty() || layer_blur_sigma.is_some() {
let id =
builder.push_effects_filter(&[], &inners, layer_blur_sigma, scale, true);
parts.push(format!("filter=\"url(#{id})\""));
}
}
}
}
if parts.is_empty() {

View File

@ -1,8 +1,11 @@
use crate::error::Result;
use crate::shapes::{Shape, Stroke};
use crate::shapes::{Shadow, Shape};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::document::{
content_effect_attrs, opacity_blend_attrs, push_container_drop_filter,
shape_with_selrect_outset, SvgLayerCanvas,
};
use super::images::{emit_fills, emit_strokes};
use super::render_tree;
use crate::render::RenderResources;
@ -14,11 +17,61 @@ pub(super) fn render_frame(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(builder, element);
// Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer
// before the shadow composite).
let composite = opacity_blend_attrs(element);
if let Some(attrs) = &composite {
builder.open_group(attrs);
}
// One silhouette pass per drop: geometric offset+spread in local space
// (GPU), filter only blurs/tints — keeps stroke-ring width and rotation.
if !builder.suppress_filters {
let drops: Vec<Shadow> = element.drop_shadows_visible().copied().collect();
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_frame_body(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);
}
// Content pass uses builder.silhouette_spread (0 after this frame's own
// drop; still set when nested inside a parent silhouette — matches
// render_leaf so nested board fills outset with inherited spread).
render_frame_body(builder, shared, element, tree, scale)?;
if effects.is_some() {
builder.close_group();
}
if composite.is_some() {
builder.close_group();
}
Ok(())
}
fn render_frame_body(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let spread = builder.silhouette_spread;
let clipped = element.clip_content;
if clipped {
let clip_id = builder.unique("clip");
@ -26,32 +79,43 @@ pub(super) fn render_frame(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
// Frame background (frame space), with linked `<image>` for image fills.
if !element.fills.is_empty() {
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
// Fills: GPU `fills::render` outsets the rect for drop-shadow spread.
let mask = shape_with_selrect_outset(element, spread);
let matrix = builder.silhouette_draw_matrix(element);
emit_fills(
builder,
shared,
&mask,
&mask.fills,
tree,
scale,
Some(matrix),
)?;
}
// Children (absolute coords).
let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect();
for child_id in &children {
render_tree(builder, shared, child_id, tree, scale)?;
}
// Close content clip before strokes. Outer (and half of center) strokes
// extend past the frame bounds; keeping them under clip-path hides them.
// Matches GPU: clipped-frame strokes render in exit without the frame clip.
if clipped {
builder.close_group();
}
// Strokes over children (frame space), outside the content clip.
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
// Strokes: GPU ignores Rect/Frame stroke outset for drop spread. Use
// emit_strokes (image + solid) under the silhouette/content CTM.
let visible_strokes: Vec<_> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
emit_strokes(builder, shared, element, &visible_strokes, scale)?;
}
if effects.is_some() {
builder.close_group();
let matrix = builder.silhouette_draw_matrix(element);
emit_strokes(
builder,
shared,
element,
&visible_strokes,
scale,
Some(matrix),
)?;
}
Ok(())
}

View File

@ -1,8 +1,10 @@
use crate::error::Result;
use crate::shapes::Shape;
use crate::shapes::{Shadow, Shape};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::document::{
content_effect_attrs, opacity_blend_attrs, push_container_drop_filter, SvgLayerCanvas,
};
use super::render_tree;
use crate::render::RenderResources;
@ -13,7 +15,33 @@ pub(super) fn render_group(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(builder, element);
// Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer
// before the shadow composite).
let composite = opacity_blend_attrs(element);
if let Some(attrs) = &composite {
builder.open_group(attrs);
}
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_group_children(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);
}
@ -22,14 +50,27 @@ pub(super) fn render_group(
// 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.
let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect();
for child_id in &children {
render_tree(builder, shared, child_id, tree, scale)?;
}
render_group_children(builder, shared, element, tree, scale)?;
if effects.is_some() {
builder.close_group();
}
if composite.is_some() {
builder.close_group();
}
Ok(())
}
fn render_group_children(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
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(())
}

View File

@ -15,6 +15,9 @@ use crate::render::RenderResources;
/// Non-image fills go through Skia's SVG canvas. Image fills with a registered
/// source URL become native linked `<image>` elements (see `store_image_url`);
/// without a URL they fall back to Skia (base64-embed) when a CPU image exists.
///
/// `draw_matrix` is the leaf CTM (container drop silhouettes pass a local-offset
/// matrix so linked images move with solid fills).
pub(super) fn emit_fills(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
@ -22,19 +25,20 @@ pub(super) fn emit_fills(
fills: &[Fill],
tree: ShapesPoolRef,
scale: f32,
draw_matrix: Option<skia_safe::Matrix>,
) -> Result<()> {
if fills.is_empty() {
return Ok(());
}
let matrix = draw_matrix.unwrap_or_else(|| shape.centered_transform());
// fills[0] is the topmost layer; draw bottom → top.
for fill in fills.iter().rev() {
match fill {
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
emit_image_fill(builder, shared, shape, image_fill, tree)?;
emit_image_fill(builder, shared, shape, image_fill, tree, matrix)?;
}
fill => {
let matrix = shape.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
@ -58,16 +62,27 @@ fn emit_image_fill(
shape: &Shape,
image_fill: &ImageFill,
tree: ShapesPoolRef,
draw_matrix: skia_safe::Matrix,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
};
let clip_id = builder.unique("imgclip");
// Clip uses builder.silhouette_offset (same space as draw_matrix during a
// container drop silhouette pass).
builder.push_clip_path(&clip_id, shape, tree);
let href = xml_escape_attr(url);
let dest_rect = get_image_dest_rect(&shape.selrect(), image_fill);
emit_linked_image_element(builder, shape, image_fill, dest_rect, &href, &clip_id);
emit_linked_image_element(
builder,
shape,
image_fill,
dest_rect,
&href,
&clip_id,
draw_matrix,
);
Ok(())
}
@ -76,23 +91,27 @@ fn emit_image_fill(
/// Image strokes with a registered URL become a linked `<image>` clipped to the
/// stroke silhouette (Skia drops the GPU save_layer + SrcIn path). Other strokes
/// go through [`VectorRenderer`].
///
/// `draw_matrix` overrides the leaf CTM (container drop silhouettes pass a
/// local-offset matrix; `None` uses `centered_transform`).
pub(super) fn emit_strokes(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
shape: &Shape,
strokes: &[&Stroke],
scale: f32,
draw_matrix: Option<skia_safe::Matrix>,
) -> Result<()> {
if strokes.is_empty() {
return Ok(());
}
let matrix = shape.centered_transform();
let matrix = draw_matrix.unwrap_or_else(|| shape.centered_transform());
// strokes[0] is topmost; draw bottom -> top.
for stroke in strokes.iter().rev() {
match &stroke.fill {
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
emit_image_stroke(builder, shared, shape, stroke, image_fill, scale)?;
emit_image_stroke(builder, shared, shape, stroke, image_fill, scale, matrix)?;
}
_ => {
let canvas = builder.canvas();
@ -115,6 +134,7 @@ fn emit_image_stroke(
stroke: &Stroke,
image_fill: &ImageFill,
scale: f32,
draw_matrix: skia_safe::Matrix,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
@ -125,7 +145,7 @@ fn emit_image_stroke(
{
let cv: &skia_safe::Canvas = &canvas;
cv.save();
cv.concat(&shape.centered_transform());
cv.concat(&draw_matrix);
if !paint_svg_stroke_silhouette(cv, shape, stroke, scale) {
cv.restore();
return Ok(());
@ -136,7 +156,15 @@ fn emit_image_stroke(
let href = xml_escape_attr(url);
let dest = image_stroke_dest_rect(shape, stroke);
emit_linked_image_element(builder, shape, image_fill, dest, &href, &clip_id);
emit_linked_image_element(
builder,
shape,
image_fill,
dest,
&href,
&clip_id,
draw_matrix,
);
Ok(())
}
@ -160,13 +188,17 @@ fn image_stroke_dest_rect(shape: &Shape, stroke: &Stroke) -> MathRect {
}
/// Emits `<g clip-path>` + `<image href>` at `dest_rect`, under the page CTM.
///
/// `draw_matrix` is the shape-local CTM (must include container drop silhouette
/// offset when drawing under a parent shadow filter).
pub(super) fn emit_linked_image_element(
builder: &mut SvgLayerCanvas,
shape: &Shape,
_shape: &Shape,
image_fill: &ImageFill,
dest_rect: MathRect,
href: &str,
clip_id: &str,
draw_matrix: skia_safe::Matrix,
) {
let opacity = image_fill.opacity() as f32 / 255.0;
let preserve = if image_fill.keep_aspect_ratio() {
@ -174,7 +206,7 @@ pub(super) fn emit_linked_image_element(
} else {
"none"
};
let transform = builder.page_shape_matrix_attr(shape);
let transform = builder.page_draw_matrix_attr(&draw_matrix);
let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
String::new()

View File

@ -60,8 +60,8 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
/// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native
/// `<clipPath>`.
///
/// Layer blur is re-emitted as a native SVG `feGaussianBlur` filter wrapper.
/// Shadows, masks, and text strokes still need dedicated SVG re-emission.
/// Layer blur and drop/inner shadows are re-emitted as a native SVG `<filter>`
/// wrapper. Masks and text strokes still need dedicated SVG re-emission.
/// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines;
/// image-filled strokes use a linked `<image>` clipped to the stroke.
pub fn render_to_svg(
@ -135,7 +135,7 @@ use frames::render_frame;
use groups::render_group;
use text::render_text_fill;
use document::effect_attrs;
use document::{effect_attrs, 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)`.
@ -193,40 +193,58 @@ fn render_leaf(
}
{
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(_)) {
render_text_fill(builder, shared, 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)?;
if morph_id.is_some() {
builder.close_group();
}
} else if matches!(element.shape_type, Type::SVGRaw(_)) {
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
canvas.concat(&draw_matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_svg(element)?;
canvas.restore();
} else {
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
emit_fills(
builder,
shared,
&fill_shape,
&fill_shape.fills,
tree,
scale,
Some(draw_matrix),
)?;
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fill_inner_shadows(element)?;
canvas.restore();
// 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)?;
if !element.has_fills() {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
for stroke in &visible_strokes {
renderer.draw_stroke_inner_shadows(element, stroke)?;
}
canvas.restore();
}
emit_strokes(
builder,
shared,
element,
&visible_strokes,
scale,
Some(draw_matrix),
)?;
}
}
}

View File

@ -0,0 +1,16 @@
---
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="144" viewBox="0 0 200 144"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="200" height="144" 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 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><clipPath id="clip1" clipPathUnits="userSpaceOnUse">
<rect transform="translate(0 24)" width="200" height="120"/>
</clipPath><clipPath id="clip2" clipPathUnits="userSpaceOnUse">
<rect width="200" height="120"/>
</clipPath></defs><g filter="url(#fx0)"><g clip-path="url(#clip1)">
<rect fill="#F0F0F0" transform="translate(0 24)" width="200" height="120"/>
<rect fill="#00C800" transform="translate(0 24)" x="20" y="20" width="160" height="80"/>
</g></g><g clip-path="url(#clip2)">
<rect fill="#F0F0F0" width="200" height="120"/>
<rect fill="#00C800" x="20" y="20" width="160" height="80"/>
</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="237.64102" height="157.64102" viewBox="0 0 237.64102 157.64102"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="237.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.39215687 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#F0F0F0" transform="translate(18.8205 18.8205)" width="200" height="120"/>
<rect fill="#00C800" transform="translate(18.8205 18.8205)" x="20" y="20" width="80" height="60"/>
</g>
<rect fill="#F0F0F0" transform="translate(18.8205 10.8205)" width="200" height="120"/>
<rect fill="#00C800" transform="translate(18.8205 10.8205)" x="20" y="20" width="80" height="60"/>
</svg>

View File

@ -3,7 +3,7 @@ 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="123.78461" viewBox="0 0 223.7846 123.78461"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.9641016"/></filter></defs><g filter="url(#blur0)">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="223.7846" height="123.78461" viewBox="0 0 223.7846 123.78461"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="223.7846" height="123.78461" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><feGaussianBlur in="shape" stdDeviation="3.9641016"/></filter></defs><g filter="url(#fx0)">
<rect fill="blue" transform="translate(11.8923 11.8923)" width="90" height="100"/>
<rect fill="#00C800" transform="translate(11.8923 11.8923)" x="110" width="90" height="100"/>
</g></svg>

View File

@ -0,0 +1,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)">
<rect fill="red" transform="translate(11.3564 9.35641)" width="100" height="80"/>
</g></svg>

View File

@ -0,0 +1,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="123.78461" height="103.78461" viewBox="0 0 123.78461 103.78461"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="123.78461" height="103.78461" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><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="hardAlpha"/><feOffset in="hardAlpha" dx="2" dy="3" result="off"/><feGaussianBlur in="off" stdDeviation="3.9641016" result="blurred"/><feComposite in="blurred" in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" result="shadow"/><feColorMatrix in="shadow" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.7058824 0" result="colored"/><feBlend mode="normal" in="colored" in2="shape" result="inner0"/></filter></defs><g filter="url(#fx0)">
<rect fill="#0080FF" transform="translate(9.8923 8.8923)" width="100" height="80"/>
</g></svg>

View File

@ -3,6 +3,6 @@ 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="137.64102" height="117.64102" viewBox="0 0 137.64102 117.64102"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="6.2735023"/></filter></defs><g filter="url(#blur0)">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="137.64102" height="117.64102" viewBox="0 0 137.64102 117.64102"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="137.64102" height="117.64102" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><feGaussianBlur in="shape" stdDeviation="6.2735023"/></filter></defs><g filter="url(#fx0)">
<rect fill="red" transform="translate(18.8205 18.8205)" width="100" height="80"/>
</g></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="232.4282" height="152.4282" viewBox="0 0 232.4282 152.4282"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="232.4282" height="152.4282" 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.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g filter="url(#fx0)">
<path transform="translate(20 20)" d="M200 0L0 0L0 120L200 120L200 0ZM5 5L5 115L195 115L195 5L5 5Z" fill-rule="evenodd"/>
</g>
<path d="M200 0L0 0L0 120L200 120L200 0ZM5 5L5 115L195 115L195 5L5 5Z" fill-rule="evenodd"/>
</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="148" viewBox="0 0 200 148"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="200" height="148" 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 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter></defs><g opacity="0.5"><g filter="url(#fx0)">
<rect fill="#3D7BFF" transform="translate(0 28)" width="200" height="120"/>
</g>
<rect fill="#3D7BFF" width="200" height="120"/>
</g></svg>

View File

@ -0,0 +1,18 @@
---
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="332.4282" height="212.4282" viewBox="0 0 332.4282 212.4282"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="332.4282" height="212.4282" 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.8980392 0 0 0 0 0.0627451 0 0 0 0 0.13725491 0 0 0 0.5019608 0" result="colored"/><feBlend mode="normal" in="colored" in2="bg" result="drop0"/></filter><filter id="txmorph1" filterUnits="userSpaceOnUse" x="0" y="0" width="332.4282" height="212.4282" color-interpolation-filters="sRGB"><feMorphology in="SourceAlpha" operator="dilate" radius="4" result="m"/><feFlood flood-color="#000000" flood-opacity="1" result="f"/><feComposite in="f" in2="m" operator="in"/></filter><filter id="fx2" filterUnits="userSpaceOnUse" x="0" y="0" width="332.4282" height="212.4282" 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"/><feMorphology in="alpha" operator="dilate" radius="4" result="spread"/><feOffset in="spread" dx="10" dy="10" result="off"/><feGaussianBlur in="off" stdDeviation="2.809401" 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)"><g filter="url(#txmorph1)">
<text transform="translate(20 20)" font-size="40" font-family="Source Sans Pro" x="40, 66.074219, 92.617188, 112.16797" y="76">
HOLA
</text>
</g>
<path transform="translate(20 20)" d="M300 0L0 0L0 180L300 180L300 0ZM5 5L5 175L295 175L295 5L5 5Z" fill-rule="evenodd"/>
</g><g filter="url(#fx2)">
<text font-size="40" font-family="Source Sans Pro" x="40, 66.074219, 92.617188, 112.16797" y="76">
HOLA
</text>
</g>
<path d="M300 0L0 0L0 180L300 180L300 0ZM5 5L5 175L295 175L295 5L5 5Z" fill-rule="evenodd"/>
</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="330" height="160" viewBox="0 0 330 160"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="330" 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.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><clipPath id="imgclip1" clipPathUnits="userSpaceOnUse">
<rect transform="translate(40 40)" width="120" height="120"/>
</clipPath><clipPath id="imgclip2" clipPathUnits="userSpaceOnUse">
<rect width="120" height="120"/>
</clipPath></defs><g filter="url(#fx0)"><g clip-path="url(#imgclip1)"><image href="images/test-fill.svg" x="0" y="0" width="120" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 40 40)"/></g>
<rect fill="#3D7BFF" transform="translate(40 40)" x="170" width="120" height="120"/>
</g><g clip-path="url(#imgclip2)"><image href="images/test-fill.svg" x="0" y="0" width="120" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g>
<rect fill="#3D7BFF" x="170" width="120" height="120"/>
</svg>

View File

@ -0,0 +1,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)">
<rect fill="#3D7BFF" transform="translate(70.782 70.782)" width="100" height="100"/>
</g></svg>

View File

@ -0,0 +1,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)">
<rect fill="red" transform="matrix(0 1 -1 0 80 0)" width="100" height="80"/>
</g></svg>

View File

@ -0,0 +1,13 @@
---
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)">
<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"/>
</g>
<path d="M260 0L0 0L0 140L260 140L260 0ZM5 5L5 135L255 135L255 5L5 5Z" fill-rule="evenodd"/>
</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="388" height="268" viewBox="0 0 388 268"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="388" height="268" 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 filter="url(#fx0)">
<rect fill="#3D7BFF" transform="translate(24 24)" x="-4" y="6" width="228" height="208"/>
<rect fill="#00C800" transform="translate(24 24)" x="226" y="46" width="108" height="108"/>
</g>
<rect fill="#3D7BFF" transform="translate(24 24)" x="20" y="30" width="180" height="160"/>
<rect fill="#00C800" transform="translate(24 24)" x="250" y="70" width="60" height="60"/>
</svg>

View File

@ -0,0 +1,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="100" height="80" viewBox="0 0 100 80">
<rect fill="red" width="100" height="80"/>
</svg>

View File

@ -0,0 +1,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)">
<rect fill="#3D7BFF" transform="translate(11.8923 0)" width="200" height="2"/>
</g></svg>

View File

@ -0,0 +1,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)">
<rect fill="#3D7BFF" transform="translate(4.4282 4.4282)" width="16" height="16"/>
</g></svg>

View File

@ -1,8 +1,8 @@
use super::fixtures::*;
use crate::shapes::{
radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, SolidColor,
StrokeCap, StrokeKind,
radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, Shadow,
ShadowStyle, SolidColor, StrokeCap, StrokeKind,
};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@ -114,8 +114,8 @@ fn exports_leaf_layer_blur_as_fe_gaussian_blur() {
"stdDeviation must match canvas radius_to_sigma(value * scale): {svg}"
);
assert!(
svg.contains("filter=\"url(#blur"),
"shape group must reference the blur filter: {svg}"
svg.contains("filter=\"url(#fx"),
"shape group must reference the effects filter: {svg}"
);
insta::assert_snapshot!(svg);
}
@ -138,7 +138,7 @@ fn skips_hidden_layer_blur() {
let svg = render(&pool, id);
assert!(
!svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#blur"),
!svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#fx"),
"hidden layer blur must not emit a filter: {svg}"
);
insta::assert_snapshot!(svg);
@ -185,9 +185,7 @@ fn exports_group_layer_blur_wrapping_children() {
"group layer blur stdDeviation: {svg}"
);
// Filter wrapper must open before child geometry.
let filter_pos = svg
.find("filter=\"url(#blur")
.expect("group filter wrapper");
let filter_pos = svg.find("filter=\"url(#fx").expect("group filter wrapper");
let child_pos = svg.find("fill=\"#").expect("child fill");
assert!(
filter_pos < child_pos,
@ -196,6 +194,843 @@ fn exports_group_layer_blur_wrapping_children() {
insta::assert_snapshot!(svg);
}
#[test]
fn exports_leaf_drop_shadow_as_svg_filter() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(255, 0, 0),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(128, 0, 0, 0),
8.0,
0.0,
(4.0, 6.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
let expected_sigma = radius_to_sigma(8.0);
assert!(
svg.contains("feOffset") && svg.contains(r#"dx="4""#) && svg.contains(r#"dy="6""#),
"drop shadow must 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}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn leaf_drop_offset_follows_rotation_in_user_space() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(255, 0, 0),
);
{
let shape = pool.get_mut(&id).unwrap();
// 90° CCW: local (+10, 0) → user-space (0, 10).
let (c, s) = (0.0_f32, 1.0_f32);
shape.set_transform(c, s, -s, c, 0.0, 0.0);
shape.set_rotation(90.0);
shape.add_shadow(Shadow::new(
skia::Color::from_argb(128, 56, 0, 238),
0.0,
0.0,
(10.0, 0.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert!(
svg.contains(r#"dx="0""#) && svg.contains(r#"dy="10""#),
"rotated leaf drop must map local offset into filter user space: {svg}"
);
assert!(
!svg.contains(r#"dx="10""#),
"must not keep unmapped local dx for rotated leaf: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn small_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
// 16×16 + offset(4,4) blur 4: objectBoundingBox ±50% only leaves 8px margin,
// but reach is ~|offset|+3σ ≈ 12px — corners crop unless the filter is
// sized in userSpaceOnUse to the page (already padded via extrect).
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 16.0, 16.0),
skia::Color::from_rgb(61, 123, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(153, 0, 0, 0),
4.0,
0.0,
(4.0, 4.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert_filter_covers_page(&svg);
insta::assert_snapshot!(svg);
}
#[test]
fn large_blur_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
// 100×100 blur 40: ±50% of bbox = 50px, 3σ≈71px — halo crops with
// objectBoundingBox percentages.
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 100.0),
skia::Color::from_rgb(61, 123, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(178, 0, 0, 0),
40.0,
0.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert_filter_covers_page(&svg);
let sigma = radius_to_sigma(40.0);
assert!(
svg.contains(&format!("stdDeviation=\"{sigma}\"")),
"large blur sigma must remain in the filter: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn sliver_leaf_drop_shadow_filter_covers_page_not_bbox_percent() {
// 200×2 + offset(0,12) blur 6: objectBoundingBox height is only 4px —
// the shadow disappears. userSpaceOnUse page region keeps it.
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 200.0, 2.0),
skia::Color::from_rgb(61, 123, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(204, 0, 0, 0),
6.0,
0.0,
(0.0, 12.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, id);
assert_filter_covers_page(&svg);
assert!(
svg.contains(r#"dy="12""#),
"sliver drop must keep its offset: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_leaf_inner_shadow_as_svg_filter() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(0, 128, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_argb(180, 0, 0, 0),
6.0,
0.0,
(2.0, 3.0),
ShadowStyle::Inner,
false,
));
}
let svg = render(&pool, id);
assert!(
svg.contains("feComposite") && svg.contains("hardAlpha"),
"inner shadow must use classic composite graph: {svg}"
);
assert!(
svg.contains("filter=\"url(#fx"),
"inner shadow must wrap the shape: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn skips_hidden_shadows() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
skia::Color::from_rgb(255, 0, 0),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::BLACK,
8.0,
0.0,
(4.0, 4.0),
ShadowStyle::Drop,
true,
));
}
let svg = render(&pool, id);
assert!(
!svg.contains("feOffset") && !svg.contains("filter=\"url(#fx"),
"hidden shadow must not emit a filter: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_frame_drop_shadow_wrapping_children() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(240, 240, 240),
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_shadow(Shadow::new(
skia::Color::from_argb(100, 0, 0, 0),
10.0,
0.0,
(0.0, 8.0),
ShadowStyle::Drop,
false,
));
}
add_solid_rect(
&mut pool,
child,
frame_id,
(20.0, 20.0, 100.0, 80.0),
skia::Color::from_rgb(0, 200, 0),
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
let svg = render(&pool, frame_id);
assert!(
svg.contains("filter=\"url(#fx"),
"frame drop shadow must emit a filter: {svg}"
);
assert!(
!svg.contains("SourceGraphic"),
"container drop filter must be shadow-only (no SourceGraphic): {svg}"
);
// Silhouette under the filter, then real content without nesting the filter.
let filter_pos = svg.find("filter=\"url(#fx").expect("frame filter");
let child_pos = svg.find("fill=\"#").expect("child fill");
assert!(
filter_pos < child_pos,
"frame shadow silhouette must precede content: {svg}"
);
let fill_count = svg.matches("fill=\"#").count();
assert!(
fill_count >= 2,
"silhouette + content must both draw fills: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn clipped_frame_drop_shadow_clip_follows_silhouette_offset() {
// clip=ON + drop offset: silhouette fills/children move with
// silhouette_draw_matrix, so the board clipPath must move too — otherwise
// the unshifted clip truncates the shadow (F1a / show-content=false).
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(240, 240, 240),
true,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_shadow(Shadow::new(
skia::Color::from_argb(140, 0, 0, 0),
0.0,
0.0,
(0.0, 24.0),
ShadowStyle::Drop,
false,
));
}
add_solid_rect(
&mut pool,
child,
frame_id,
(20.0, 20.0, 180.0, 100.0),
skia::Color::from_rgb(0, 200, 0),
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
let svg = render(&pool, frame_id);
assert!(
svg.contains("filter=\"url(#fx"),
"clipped frame drop shadow must emit a filter: {svg}"
);
assert!(
svg.matches("<clipPath").count() >= 2,
"silhouette and content each need a clipPath: {svg}"
);
let filter_open = svg.find("filter=\"url(#fx").expect("frame filter");
let filter_close = svg[filter_open..]
.find("</g>")
.map(|i| filter_open + i)
.expect("silhouette group close");
let silhouette = &svg[filter_open..=filter_close];
let clip_ref = silhouette
.find("clip-path=\"url(#")
.and_then(|i| {
let start = i + "clip-path=\"url(#".len();
let end = silhouette[start..].find(')')?;
Some(&silhouette[start..start + end])
})
.expect("silhouette must reference a clipPath");
let clip_def_start = svg
.find(&format!("<clipPath id=\"{clip_ref}\""))
.expect("silhouette clipPath def");
let clip_def_end = svg[clip_def_start..]
.find("</clipPath>")
.map(|i| clip_def_start + i)
.expect("clipPath close");
let clip_geom = &svg[clip_def_start..clip_def_end];
// Content clip (second clipPath) stays unshifted; silhouette clip must
// carry the local drop offset (0, 24) like silhouette fills.
assert!(
clip_geom.contains("translate(") && clip_geom.contains(" 24"),
"silhouette clipPath must follow drop offset (0,24): {clip_geom}\nfull: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn group_drop_silhouette_shifts_image_fill_children() {
// Container drop silhouettes must offset linked <image> fills the same way
// as solid fills (F3). Clip already follows silhouette_offset; the image
// CTM must use draw_matrix too.
let mut pool = ShapesPool::new();
let group_id = uid(1);
let image_child = uid(2);
let solid_child = uid(3);
let image_id = uid(42);
add_image_rect(
&mut pool,
image_child,
group_id,
(0.0, 0.0, 120.0, 120.0),
image_id,
true,
255,
);
add_solid_rect(
&mut pool,
solid_child,
group_id,
(170.0, 0.0, 290.0, 120.0),
skia::Color::from_rgb(61, 123, 255),
);
add_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 290.0, 120.0),
&[image_child, solid_child],
);
{
let group = pool.get_mut(&group_id).unwrap();
group.add_shadow(Shadow::new(
skia::Color::from_rgb(229, 16, 35),
0.0,
0.0,
(40.0, 40.0),
ShadowStyle::Drop,
false,
));
}
let svg = render_with(&pool, group_id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("filter=\"url(#fx"),
"group drop shadow must emit a filter: {svg}"
);
let filter_open = svg.find("filter=\"url(#fx").expect("group filter");
let silhouette = &svg[filter_open..];
// Content pass repeats the image without the silhouette offset matrix.
let content_image = silhouette
.match_indices("<image")
.nth(1)
.map(|(i, _)| filter_open + i);
let silhouette = match content_image {
Some(end) => &svg[filter_open..end],
None => silhouette,
};
assert!(
silhouette.contains("<image") && silhouette.contains(TEST_IMAGE_URL),
"silhouette must include the image-fill child: {silhouette}\nfull: {svg}"
);
assert!(
silhouette.contains("fill=\"#3D7BFF\"") || silhouette.contains("fill=\"#3d7bff\""),
"silhouette must include the solid child: {silhouette}\nfull: {svg}"
);
assert!(
silhouette.contains(r#"translate(40 40)"#),
"solid silhouette child must apply group drop offset: {silhouette}\nfull: {svg}"
);
assert!(
silhouette.contains("matrix(1 0 0 1 40 40)"),
"image silhouette child must apply the same local offset via draw_matrix: {silhouette}\nfull: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn nested_frame_fill_outsets_under_parent_drop_spread() {
// Outer board: no fill, drop spread 24. Nested board fill must outset by
// the inherited silhouette_spread (F5) — same as leaf children via
// render_leaf. Hardcoding 0.0 on the nested frame content pass left the
// nested board hugging its true edge while the leaf got the red ring.
let mut pool = ShapesPool::new();
let outer = uid(1);
let nested = uid(2);
let leaf = uid(3);
add_frame(
&mut pool,
outer,
Uuid::nil(),
(0.0, 0.0, 340.0, 220.0),
skia::Color::TRANSPARENT,
false,
);
{
let frame = pool.get_mut(&outer).unwrap();
frame.clear_fills();
frame.add_shadow(Shadow::new(
skia::Color::from_rgb(229, 16, 35),
0.0,
24.0,
(0.0, 0.0),
ShadowStyle::Drop,
false,
));
}
add_frame(
&mut pool,
nested,
outer,
(20.0, 30.0, 200.0, 190.0),
skia::Color::from_rgb(61, 123, 255),
false,
);
add_solid_rect(
&mut pool,
leaf,
outer,
(250.0, 70.0, 310.0, 130.0),
skia::Color::from_rgb(0, 200, 0),
);
{
let frame = pool.get_mut(&outer).unwrap();
frame.add_child(nested);
frame.add_child(leaf);
}
let svg = render(&pool, outer);
assert!(
svg.contains("filter=\"url(#fx"),
"outer drop shadow must emit a filter: {svg}"
);
let filter_open = svg.find("filter=\"url(#fx").expect("outer filter");
let after = &svg[filter_open..];
// Content pass redraws the nested board at true size (180×160); silhouette
// must use the spread-outset size (180+48)×(160+48).
let silhouette_end = after
.find("width=\"180\"")
.map(|i| filter_open + i)
.expect("content nested board at true size");
let silhouette = &svg[filter_open..silhouette_end];
assert!(
silhouette.contains("width=\"228\"") && silhouette.contains("height=\"208\""),
"nested board fill must outset by parent spread 24 (180+48, 160+48): {silhouette}\nfull: {svg}"
);
assert!(
silhouette.contains("width=\"108\"") && silhouette.contains("height=\"108\""),
"leaf fill must also outset by parent spread 24 (60+48): {silhouette}\nfull: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn frame_drop_silhouette_inherits_board_opacity() {
// GPU opens the opacity save_layer before the shadow composite, so a board
// at opacity 0.5 casts a half-strength drop. The silhouette filter group
// must sit inside the opacity wrapper (F6a), not beside it.
let mut pool = ShapesPool::new();
let frame_id = uid(1);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::from_rgb(61, 123, 255),
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.set_opacity(0.5);
frame.add_shadow(Shadow::new(
skia::Color::BLACK,
0.0,
0.0,
(0.0, 28.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, frame_id);
let opacity_pos = svg.find(r#"opacity="0.5""#).expect("board opacity wrapper");
let filter_pos = svg
.find("filter=\"url(#fx")
.expect("drop silhouette filter");
assert!(
opacity_pos < filter_pos,
"opacity must wrap the drop silhouette (GPU order): {svg}"
);
// Silhouette group is nested inside the opacity group — closing opacity
// after the filter group means the shadow is attenuated.
let after_opacity = &svg[opacity_pos..];
assert!(
after_opacity.contains("filter=\"url(#fx"),
"drop silhouette must be inside the opacity wrapper: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn nested_child_drop_shadow_is_not_refiltered_by_frame() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let child = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 260.0, 140.0),
skia::Color::TRANSPARENT,
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.clear_fills();
frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK));
frame.add_shadow(Shadow::new(
skia::Color::from_rgb(251, 243, 0),
0.0,
0.0,
(20.0, 20.0),
ShadowStyle::Drop,
false,
));
}
add_solid_rect(
&mut pool,
child,
frame_id,
(40.0, 30.0, 120.0, 70.0),
skia::Color::from_rgb(239, 83, 80),
);
{
let shape = pool.get_mut(&child).unwrap();
shape.add_shadow(Shadow::new(
skia::Color::from_rgb(56, 0, 238),
0.0,
0.0,
(10.0, 10.0),
ShadowStyle::Drop,
false,
));
}
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
let svg = render(&pool, frame_id);
let filter_attrs: Vec<_> = svg.match_indices("filter=\"url(#fx").collect();
assert_eq!(
filter_attrs.len(),
2,
"expect frame silhouette filter + child content filter only: {svg}"
);
// Child's filter must not sit inside the frame's filtered group.
let frame_filter_open = svg.find("<g filter=\"url(#fx").expect("frame filter group");
let frame_filter_close = svg[frame_filter_open..]
.find("</g>")
.map(|i| frame_filter_open + i)
.expect("close frame filter group");
let child_filter = svg.rfind("<g filter=\"url(#fx").expect("child filter");
assert!(
child_filter > frame_filter_close,
"child drop filter must be outside frame drop group to avoid shadow-of-shadow: {svg}"
);
assert!(
svg.contains(r#"dx="10""#),
"child leaf drop must keep filter offset: {svg}"
);
// Frame container drops apply offset geometrically (filter dx=0).
assert!(
svg.contains(r#"dx="0""#) || svg.matches(r#"dx=""#).count() >= 1,
"frame drop filter must not re-offset in user space: {svg}"
);
// Stroke-ring silhouette (border shadow), not a solid board fill.
let silhouette = &svg[frame_filter_open..=frame_filter_close];
assert!(
silhouette.contains("fill-rule=\"evenodd\"") || silhouette.contains("<path"),
"frame stroke must be in the drop-shadow silhouette: {silhouette}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn frame_drop_silhouette_offsets_child_text() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
let text_id = uid(2);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 300.0, 180.0),
skia::Color::TRANSPARENT,
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.clear_fills();
frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK));
frame.add_shadow(Shadow::new(
skia::Color::from_argb(128, 229, 16, 35),
4.0,
4.0,
(20.0, 20.0),
ShadowStyle::Drop,
false,
));
}
add_solid_text(
&mut pool,
text_id,
(40.0, 40.0, 200.0, 120.0),
"HOLA",
40.0,
skia::Color::BLACK,
);
{
let text = pool.get_mut(&text_id).unwrap();
text.set_parent(frame_id);
text.add_shadow(Shadow::new(
skia::Color::from_argb(128, 56, 0, 238),
4.0,
4.0,
(10.0, 10.0),
ShadowStyle::Drop,
false,
));
}
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(text_id);
}
let svg = render(&pool, frame_id);
let frame_filter_open = svg.find("<g filter=\"url(#fx").expect("frame filter group");
let frame_filter_close = svg[frame_filter_open..]
.find("</g>")
.map(|i| frame_filter_open + i)
.expect("close frame filter group");
let silhouette = &svg[frame_filter_open..=frame_filter_close];
assert!(
silhouette.contains("<text"),
"frame drop silhouette must include child text: {silhouette}"
);
assert!(
silhouette.contains(r#"transform="translate(20 20)""#),
"silhouette text must apply frame drop offset in local space: {silhouette}"
);
assert!(
silhouette.contains("feMorphology") || svg.contains("txmorph"),
"silhouette text must dilate for frame drop spread: {svg}"
);
assert!(
svg.contains(r#"operator="dilate""#) && svg.contains(r#"radius="4""#),
"text silhouette spread must dilate by frame shadow spread: {svg}"
);
// Content text (outside silhouette) must stay unshifted.
let content = &svg[frame_filter_close..];
let content_text_start = content.find("<text").expect("content text");
let content_text_end = content[content_text_start..]
.find("</text>")
.map(|i| content_text_start + i)
.expect("content text end");
let content_text = &content[content_text_start..=content_text_end];
assert!(
!content_text.contains("translate(20 20)"),
"content text must not carry silhouette offset: {content_text}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn fill_less_frame_drop_shadow_ignores_stroke_spread_outset() {
let mut pool = ShapesPool::new();
let frame_id = uid(1);
add_frame(
&mut pool,
frame_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
skia::Color::TRANSPARENT,
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.clear_fills();
frame.add_stroke(solid_stroke(StrokeKind::Inner, 5.0, skia::Color::BLACK));
frame.add_shadow(Shadow::new(
skia::Color::from_argb(128, 229, 16, 35),
4.0,
4.0,
(20.0, 20.0),
ShadowStyle::Drop,
false,
));
}
let svg = render(&pool, frame_id);
assert!(
!svg.contains("feMorphology"),
"container drop must not use feMorphology: {svg}"
);
assert!(
svg.contains(r#"dx="0""#) && svg.contains("feGaussianBlur"),
"container drop filter must blur only (offset is geometric): {svg}"
);
let filter_open = svg.find("<g filter=\"url(#fx").expect("drop group");
let filter_close = svg[filter_open..]
.find("</g>")
.map(|i| filter_open + i)
.expect("close drop group");
let silhouette = &svg[filter_open..=filter_close];
assert!(
silhouette.contains("fill-rule=\"evenodd\""),
"silhouette must stay a stroke ring: {silhouette}"
);
// GPU ignores Rect/Frame stroke outset — ring must match content selrect
// (200×120), not an expanded 208×128 path.
assert!(
silhouette.contains("M200 ") || silhouette.contains("L200 "),
"stroke silhouette must not grow with spread: {silhouette}"
);
assert!(
!silhouette.contains("M204 ") && !silhouette.contains("L204 "),
"spread must not outset frame stroke geometry: {silhouette}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_group_with_two_rects_and_group_opacity() {
let mut pool = ShapesPool::new();
@ -1369,6 +2204,32 @@ fn exports_image_fill_on_frame() {
insta::assert_snapshot!(svg);
}
fn assert_filter_covers_page(svg: &str) {
let width = svg
.split_once("width=\"")
.and_then(|(_, rest)| rest.split_once('"').map(|(w, _)| w))
.expect("svg width");
let height = svg
.split_once("height=\"")
.and_then(|(_, rest)| rest.split_once('"').map(|(h, _)| h))
.expect("svg height");
assert!(
svg.contains("filterUnits=\"userSpaceOnUse\""),
"shadow/blur filters must use userSpaceOnUse (not objectBoundingBox %): {svg}"
);
assert!(
!svg.contains("x=\"-50%\"") && !svg.contains("width=\"200%\""),
"must not size filters as a percent of the shape bbox: {svg}"
);
assert!(
svg.contains(&format!("width=\"{width}\""))
&& svg.contains(&format!("height=\"{height}\""))
&& svg.contains(r#"x="0""#)
&& svg.contains(r#"y="0""#),
"filter region must cover the export page ({width}×{height}): {svg}"
);
}
fn assert_linked_image_stroke(svg: &str) {
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),

View File

@ -13,10 +13,16 @@ use crate::render::RenderResources;
///
/// Linked image fills become `<image href>` clipped to the glyph silhouette;
/// other fills go through Skia as native `<text>`. Strokes are a later PR.
///
/// `draw_matrix` is the leaf CTM (normally `centered_transform`). During a
/// parent drop-shadow silhouette pass it must include the geometric offset
/// (`silhouette_draw_matrix`); using only `centered_transform` leaves child
/// text unshifted while strokes/fills move.
pub(super) fn render_text_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
element: &Shape,
draw_matrix: skia_safe::Matrix,
) -> Result<()> {
let text_content = element.get_text_content();
let text_content = text_content.new_bounds(element.selrect());
@ -25,14 +31,12 @@ pub(super) fn render_text_fill(
return Ok(());
}
let matrix = element.centered_transform();
for layer in 0..max_layers {
let linked = linked_image_fills_at_layer(&text_content, layer, shared);
let skip_ids: HashSet<Uuid> = linked.iter().map(|img| img.id()).collect();
for image_fill in &linked {
emit_text_image_fill(builder, shared, element, image_fill, layer)?;
emit_text_image_fill(builder, shared, element, image_fill, layer, draw_matrix)?;
}
if layer_has_skia_fills(&text_content, layer, &skip_ids) {
@ -44,7 +48,7 @@ pub(super) fn render_text_fill(
};
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
canvas.concat(&draw_matrix);
text::paint_text_paragraphs(canvas, element, &mut paragraph_builders);
canvas.restore();
}
@ -96,6 +100,7 @@ fn emit_text_image_fill(
shape: &Shape,
image_fill: &ImageFill,
layer: usize,
draw_matrix: skia_safe::Matrix,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
@ -110,13 +115,21 @@ fn emit_text_image_fill(
{
let cv: &skia_safe::Canvas = &canvas;
cv.save();
cv.concat(&shape.centered_transform());
cv.concat(&draw_matrix);
text::paint_text_paragraphs(cv, shape, &mut paragraph_builders);
cv.restore();
}
builder.finish_clip_path_fragment(&clip_id, canvas);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, shape.selrect(), &href, &clip_id);
emit_linked_image_element(
builder,
shape,
image_fill,
shape.selrect(),
&href,
&clip_id,
draw_matrix,
);
Ok(())
}