diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs
index 4de4d28bd1..a36b9ee741 100644
--- a/render-wasm/src/render/svg/document.rs
+++ b/render-wasm/src/render/svg/document.rs
@@ -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 {
"{geometry}"
));
}
+
+ /// Registers a layer-blur `` 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!(
+ "",
+ "",
+ ""
+ ),
+ 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 `` 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 {
+/// Layer blur is a native SVG `` (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 {
let mut parts: Vec = Vec::new();
let opacity = element.opacity();
@@ -187,6 +206,13 @@ pub(super) fn effect_attrs(element: &Shape) -> Option {
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 {
diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs
index 56d341ea15..f4f9f2de9c 100644
--- a/render-wasm/src/render/svg/frames.rs
+++ b/render-wasm/src/render/svg/frames.rs
@@ -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);
}
diff --git a/render-wasm/src/render/svg/groups.rs b/render-wasm/src/render/svg/groups.rs
index 5bca7d8876..cd2359baef 100644
--- a/render-wasm/src/render/svg/groups.rs
+++ b/render-wasm/src/render/svg/groups.rs
@@ -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);
}
diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs
index d990d95522..e3dde7cf84 100644
--- a/render-wasm/src/render/svg/mod.rs
+++ b/render-wasm/src/render/svg/mod.rs
@@ -60,9 +60,10 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
/// composed as native SVG `` wrappers. Frame `clip content` uses a native
/// ``.
///
-/// 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 `` 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 `` 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);
}
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap
new file mode 100644
index 0000000000..c34757a0ad
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_layer_blur_wrapping_children.snap
@@ -0,0 +1,9 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap
new file mode 100644
index 0000000000..208edb618a
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_layer_blur_as_fe_gaussian_blur.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_layer_blur.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_layer_blur.snap
new file mode 100644
index 0000000000..ed30b2c40d
--- /dev/null
+++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_layer_blur.snap
@@ -0,0 +1,8 @@
+---
+source: src/render/svg/tests.rs
+expression: svg
+---
+
+
diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs
index 35aa75c35e..b7048272a0 100644
--- a/render-wasm/src/render/svg/tests.rs
+++ b/render-wasm/src/render/svg/tests.rs
@@ -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(" 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 `` wrapper instead.
+ fn layer_blur_filter(&self, shape: &Shape) -> Option {
+ 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(
diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs
index 2db654e799..bb3c74f7f0 100644
--- a/render-wasm/src/shapes.rs
+++ b/render-wasm/src/shapes.rs
@@ -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 {
+ 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();