Export layer blur to WASM SVG (#11580)

SkSVGDevice drops paint image-filters, so re-emit visible layer blur as a
native feGaussianBlur filter on the composite <g>. Match canvas sigma via
radius_to_sigma(value * scale), and skip Skia blur filters on the SVG
VectorRenderer path so shapes do not vanish.

Closes #11380
This commit is contained in:
Alejandro Alonso 2026-09-10 14:25:47 +02:00 committed by GitHub
parent 30849babcc
commit dbe5941a23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 257 additions and 19 deletions

View File

@ -1,6 +1,6 @@
use skia_safe::{self as skia, Paint};
use crate::shapes::{Shape, Type};
use crate::shapes::{radius_to_sigma, Shape, Type};
use crate::state::ShapesPoolRef;
use crate::render::vector::draw_shape_geometry;
@ -151,6 +151,25 @@ impl SvgLayerCanvas {
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
));
}
/// Registers a layer-blur `<filter>` 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");
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>"
),
id = id,
sigma = sigma
));
id
}
}
/// Draws a clip geometry into `cv` (already set up with the page transform).
@ -171,11 +190,11 @@ fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, pai
}
/// Builds the `<g>` attribute string for a shape's composite effects (opacity,
/// blend mode). Returns `None` when the shape needs no wrapper.
/// blend mode, layer blur). Returns `None` when the shape needs no wrapper.
///
/// Layer blur / shadows are intentionally omitted here — they need native SVG
/// filter re-emission to survive `SkSVGDevice` and land in later PRs.
pub(super) fn effect_attrs(element: &Shape) -> Option<String> {
/// Layer blur is a native SVG `<filter>` (SkSVGDevice drops paint image-filters).
/// Shadows still need dedicated re-emission in a later PR.
pub(super) fn effect_attrs(builder: &mut SvgLayerCanvas, element: &Shape) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
let opacity = element.opacity();
@ -187,6 +206,13 @@ pub(super) fn effect_attrs(element: &Shape) -> Option<String> {
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 {

View File

@ -14,7 +14,7 @@ pub(super) fn render_frame(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(element);
let effects = effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}

View File

@ -13,7 +13,7 @@ pub(super) fn render_group(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(element);
let effects = effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}

View File

@ -60,9 +60,10 @@ 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>`.
///
/// Shadows, layer blur, 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.
/// Layer blur is re-emitted as a native SVG `feGaussianBlur` filter wrapper.
/// Shadows, 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(
shared: &mut RenderResources,
id: &Uuid,
@ -186,7 +187,7 @@ fn render_leaf(
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(element);
let effects = effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}

View File

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

View File

@ -1,7 +1,8 @@
use super::fixtures::*;
use crate::shapes::{
BlendMode, Fill, ImageFill, ImageFillTransform, SolidColor, StrokeCap, StrokeKind,
radius_to_sigma, BlendMode, Blur, BlurType, Fill, ImageFill, ImageFillTransform, SolidColor,
StrokeCap, StrokeKind,
};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@ -85,6 +86,116 @@ fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() {
insta::assert_snapshot!(svg);
}
#[test]
fn exports_leaf_layer_blur_as_fe_gaussian_blur() {
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 blur_value = 10.0;
{
let shape = pool.get_mut(&id).unwrap();
shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, blur_value)));
}
let svg = render(&pool, id);
let expected_sigma = radius_to_sigma(blur_value * 1.0);
assert!(
svg.contains("<filter") && svg.contains("feGaussianBlur"),
"layer blur must emit an SVG filter: {svg}"
);
assert!(
svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")),
"stdDeviation must match canvas radius_to_sigma(value * scale): {svg}"
);
assert!(
svg.contains("filter=\"url(#blur"),
"shape group must reference the blur filter: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn skips_hidden_layer_blur() {
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.set_blur(Some(Blur::new(BlurType::LayerBlur, true, 10.0)));
}
let svg = render(&pool, id);
assert!(
!svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#blur"),
"hidden layer blur must not emit a filter: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_group_layer_blur_wrapping_children() {
let mut pool = ShapesPool::new();
let group_id = uid(1);
let a = uid(2);
let b = uid(3);
add_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 100.0),
&[a, b],
);
{
let group = pool.get_mut(&group_id).unwrap();
group.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 6.0)));
}
add_solid_rect(
&mut pool,
a,
group_id,
(0.0, 0.0, 90.0, 100.0),
skia::Color::from_rgb(0, 0, 255),
);
add_solid_rect(
&mut pool,
b,
group_id,
(110.0, 0.0, 200.0, 100.0),
skia::Color::from_rgb(0, 200, 0),
);
let svg = render(&pool, group_id);
let expected_sigma = radius_to_sigma(6.0);
assert!(
svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")),
"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 child_pos = svg.find("fill=\"#").expect("child fill");
assert!(
filter_pos < child_pos,
"group blur must wrap children: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_group_with_two_rects_and_group_opacity() {
let mut pool = ShapesPool::new();

View File

@ -44,6 +44,18 @@ impl<'a> VectorRenderer<'a> {
compose_fills,
}
}
/// Layer-blur paint filter for this backend.
///
/// SVG export (`compose_fills == false`) returns `None`: `SkSVGDevice` drops
/// paint image-filters (the shape would vanish). Layer blur is re-emitted as
/// a native SVG `<filter>` wrapper instead.
fn layer_blur_filter(&self, shape: &Shape) -> Option<skia::ImageFilter> {
if !self.compose_fills {
return None;
}
shape.image_filter(1.)
}
}
impl ShapeRenderer for VectorRenderer<'_> {
@ -52,17 +64,24 @@ impl ShapeRenderer for VectorRenderer<'_> {
return Ok(());
}
let blur_filter = self.layer_blur_filter(shape);
let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_)));
if !self.compose_fills || has_image_fills {
// fills[0] is the topmost layer; draw bottom → top (matches GPU + classic SVG).
for fill in fills.iter().rev() {
match fill {
Fill::Image(image_fill) => {
draw_image_fill(self.shared, self.canvas, shape, image_fill)?;
draw_image_fill(
self.shared,
self.canvas,
shape,
image_fill,
blur_filter.as_ref(),
)?;
}
_ => {
let mut paint = fill.to_paint(&shape.selrect, true);
if let Some(filter) = shape.image_filter(1.) {
if let Some(filter) = blur_filter.clone() {
paint.set_image_filter(filter);
}
draw_shape_geometry(self.canvas, shape, &paint);
@ -75,7 +94,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
let mut paint = merge_fills(fills, shape.selrect);
paint.set_anti_alias(true);
if let Some(filter) = shape.image_filter(1.) {
if let Some(filter) = blur_filter {
paint.set_image_filter(filter);
}
@ -124,7 +143,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
}
let layer_bounds = shape.layer_bounds();
for shadow in shape.inner_shadows_visible() {
let paint = shadow.get_inner_shadow_paint(true, shape.image_filter(1.).as_ref());
let paint = shadow.get_inner_shadow_paint(true, self.layer_blur_filter(shape).as_ref());
self.canvas.save_layer(
&skia::canvas::SaveLayerRec::default()
.bounds(&layer_bounds)
@ -163,7 +182,7 @@ impl ShapeRenderer for VectorRenderer<'_> {
let text_content = text_content.new_bounds(shape.selrect());
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
let blur_filter = shape.image_filter(1.);
let blur_filter = self.layer_blur_filter(shape);
// Text drop shadows: one filter layer per shadow over fill + stroke
// silhouettes (mirrors GPU `render_text_shadows`).
@ -353,6 +372,9 @@ impl ShapeRenderer for VectorRenderer<'_> {
}
fn apply_blur_layer(&mut self, shape: &Shape) -> bool {
if !self.compose_fills {
return false;
}
let blur = match shape.blur {
Some(b) if !b.hidden && b.blur_type == BlurType::LayerBlur && b.value > 0.0 => b,
_ => return false,
@ -1033,6 +1055,7 @@ fn draw_image_fill(
canvas: &Canvas,
shape: &Shape,
image_fill: &crate::shapes::ImageFill,
blur_filter: Option<&skia::ImageFilter>,
) -> Result<()> {
// Use a CPU-backed image copy — GPU-backed images can't be drawn
// on the PDF canvas which has no GPU context.
@ -1053,8 +1076,8 @@ fn draw_image_fill(
let mut paint = Paint::default();
paint.set_anti_alias(true);
if let Some(filter) = shape.image_filter(1.) {
paint.set_image_filter(filter);
if let Some(filter) = blur_filter {
paint.set_image_filter(filter.clone());
}
canvas.draw_image_rect_with_sampling_options(

View File

@ -652,6 +652,13 @@ impl Shape {
self.background_blur.filter(|blur| !blur.hidden)
}
/// Visible layer blur (`!hidden`, `LayerBlur`, `value > 0`).
pub fn visible_layer_blur(&self) -> Option<Blur> {
self.blur.filter(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
})
}
#[cfg(test)]
pub fn add_child(&mut self, id: Uuid) {
self.children.push(id);
@ -2059,6 +2066,51 @@ mod tests {
assert_eq!(shape.visible_background_blur(), None);
}
/// Cases mirrored from Penpot MCP board `layer-blur-cases` (file "blur"):
/// leaf-visible-blur, leaf-hidden-blur, leaf-zero-blur, leaf-background-blur,
/// group-with-blur.
#[test]
fn visible_layer_blur_requires_non_hidden_positive_layer_blur() {
let mut shape = any_shape();
// leaf-visible-blur / group-with-blur
let visible = Blur::new(BlurType::LayerBlur, false, 10.0);
shape.set_blur(Some(visible));
assert_eq!(shape.visible_layer_blur(), Some(visible));
let group_blur = Blur::new(BlurType::LayerBlur, false, 6.0);
shape.set_blur(Some(group_blur));
assert_eq!(shape.visible_layer_blur(), Some(group_blur));
// leaf-hidden-blur
shape.set_blur(Some(Blur::new(BlurType::LayerBlur, true, 10.0)));
assert_eq!(shape.visible_layer_blur(), None);
// leaf-zero-blur
shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 0.0)));
assert_eq!(shape.visible_layer_blur(), None);
// no blur
shape.set_blur(None);
assert_eq!(shape.visible_layer_blur(), None);
}
#[test]
fn visible_layer_blur_ignores_background_blur() {
let mut shape = any_shape();
// leaf-background-blur: Plugin API uses `backgroundBlur`, not `blur`.
shape.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, false, 8.0)));
assert_eq!(shape.visible_layer_blur(), None);
assert_eq!(
shape.visible_background_blur(),
Some(Blur::new(BlurType::BackgroundBlur, false, 8.0))
);
let layer = Blur::new(BlurType::LayerBlur, false, 4.0);
shape.set_blur(Some(layer));
assert_eq!(shape.visible_layer_blur(), Some(layer));
}
#[test]
fn test_set_corners() {
let mut shape = any_shape();