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();