From e3e75725d468bf5f54a7f85f5d499fc262a8f149 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 24 Sep 2026 15:29:25 +0200 Subject: [PATCH] :sparkles: Export WASM SVG background blur via foreignObject (#11765) Emit an XHTML foreignObject with CSS backdrop-filter for visible background blur. The inner div uses clip-path:path(...) for the GPU fill+stroke silhouette (glyph outlines for text). The FO sits outside opacity and layer-blur filters. Stacked on the masks SVG export work. --- render-wasm/src/render.rs | 22 +- render-wasm/src/render/svg/background_blur.rs | 169 +++++++++++++++ render-wasm/src/render/svg/document.rs | 25 +++ render-wasm/src/render/svg/frames.rs | 3 + render-wasm/src/render/svg/groups.rs | 3 + render-wasm/src/render/svg/mod.rs | 10 +- ...exists_with_layer_blur_outside_filter.snap | 8 + ...frame_background_blur_before_children.snap | 9 + ...ts_group_background_blur_selrect_clip.snap | 8 + ...eaf_background_blur_as_foreign_object.snap | 8 + ..._text_background_blur_with_glyph_clip.snap | 10 + ...__tests__skips_hidden_background_blur.snap | 8 + render-wasm/src/render/svg/tests.rs | 195 ++++++++++++++++++ 13 files changed, 467 insertions(+), 11 deletions(-) create mode 100644 render-wasm/src/render/svg/background_blur.rs create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__background_blur_coexists_with_layer_blur_outside_filter.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_frame_background_blur_before_children.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_background_blur_selrect_clip.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_background_blur_as_foreign_object.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_background_blur_with_glyph_clip.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_background_blur.snap diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index cd211bff1f..ce0b71369b 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -727,12 +727,9 @@ impl RenderState { shape.frame_clip_layer_blur() } - /// Builds the background-blur clip region for a shape whose strokes - /// extend beyond the fill geometry: the fill path expanded (via union) - /// with a solid stroke coverage of the maximum outward stroke reach. - /// Dash/dot stroke styles are treated as solid, so dash gaps also get - /// a blurred backdrop. - fn background_blur_clip_path(shape: &Shape, stroke_outset: f32) -> skia::Path { + /// Fill ∪ stroke-outset silhouette for background blur (GPU, vector, SVG). + /// Dash/dot strokes are treated as solid so gaps still get a blurred backdrop. + pub(crate) fn background_blur_clip_path(shape: &Shape, stroke_outset: f32) -> skia::Path { let base = match &shape.shape_type { Type::Rect(data) if data.corners.is_some() => { let rrect = RRect::new_rect_radii(shape.selrect, data.corners.as_ref().unwrap()); @@ -749,9 +746,16 @@ impl RenderState { .unwrap_or_else(|| skia::Path::rect(shape.selrect, None)), }; - // Expand outward by the max stroke reach: a centered stroke of - // 2× the outset covers exactly `stroke_outset` beyond the path - // (the inward half disappears in the union with the fill). + Self::union_stroke_outset(base, stroke_outset) + } + + /// Unions a centered stroke of width `2 * stroke_outset` into `base`. + /// The inward half disappears in the union, leaving `stroke_outset` beyond + /// the fill outline. + pub(crate) fn union_stroke_outset(base: skia::Path, stroke_outset: f32) -> skia::Path { + if stroke_outset <= 0.0 { + return base; + } let mut paint = skia::Paint::default(); paint.set_style(skia::PaintStyle::Stroke); paint.set_stroke_width(stroke_outset * 2.0); diff --git a/render-wasm/src/render/svg/background_blur.rs b/render-wasm/src/render/svg/background_blur.rs new file mode 100644 index 0000000000..6d1e68630b --- /dev/null +++ b/render-wasm/src/render/svg/background_blur.rs @@ -0,0 +1,169 @@ +//! Background blur for WASM SVG export. +//! +//! Emits an empty XHTML `foreignObject` with CSS `backdrop-filter`, clipped to +//! the shape silhouette via `clip-path: path(...)` on the inner HTML div, then +//! paints fills/strokes on top. Text clips use glyph outline paths. Skia SVG +//! has no native backdrop filter; SVG clip/mask on the FO empties the browser +//! backdrop sample, so the clip stays on the div. + +use skia_safe as skia; + +use crate::error::Result; +use crate::render::RenderState; +use crate::shapes::text_paths::TextPaths; +use crate::shapes::{radius_to_sigma, Path, Segment, Shape, Stroke, Type}; + +use super::document::SvgLayerCanvas; + +/// Emits background-blur markup when the shape has a visible background blur. +/// No-op for `SVGRaw`, silhouette passes (`suppress_filters`), hidden/zero +/// blur, empty clip paths, or shapes without background blur. +/// +/// Call before opacity / blend / layer-blur wrappers so the glass ignores +/// shape opacity (same order as the GPU path). +pub(super) fn emit_background_blur( + builder: &mut SvgLayerCanvas, + element: &Shape, + scale: f32, +) -> Result<()> { + if builder.suppress_filters { + return Ok(()); + } + if matches!(element.shape_type, Type::SVGRaw(_)) { + return Ok(()); + } + let Some(blur) = element.visible_background_blur() else { + return Ok(()); + }; + if blur.value <= 0.0 { + return Ok(()); + } + + let sigma = radius_to_sigma(blur.value * scale); + let draw_matrix = builder.silhouette_draw_matrix(element); + let (clip, stroke_outset) = blur_clip_and_outset(element); + if clip.is_empty() { + return Ok(()); + } + emit_foreign_object( + builder, + element, + &draw_matrix, + &clip, + stroke_outset, + blur.value, + sigma, + ); + Ok(()) +} + +/// Local-space silhouette path and stroke reach used to size/clip the FO. +fn blur_clip_and_outset(element: &Shape) -> (skia::Path, f32) { + match &element.shape_type { + Type::Text(_) => { + let outset = Stroke::max_bounds_width(element.visible_strokes(), false); + (text_background_blur_clip_path(element, outset), outset) + } + Type::Group(_) => (skia::Path::rect(element.selrect(), None), 0.0), + _ => { + let outset = Stroke::max_bounds_width(element.visible_strokes(), element.is_open()); + ( + RenderState::background_blur_clip_path(element, outset), + outset, + ) + } + } +} + +/// Union of glyph outlines, optionally expanded by stroke reach. +fn text_background_blur_clip_path(element: &Shape, stroke_outset: f32) -> skia::Path { + let Type::Text(text) = &element.shape_type else { + return skia::Path::default(); + }; + + let text_paths = TextPaths::new(text.new_bounds(element.selrect())); + let mut combined: Option = None; + for path in text_paths.get_paths(element.vertical_align()) { + combined = Some(match combined { + None => path, + Some(acc) => acc.op(&path, skia::PathOp::Union).unwrap_or(acc), + }); + } + + let Some(base) = combined else { + return skia::Path::default(); + }; + + RenderState::union_stroke_outset(base, stroke_outset) +} + +/// Emits a page-space `foreignObject` whose div applies backdrop blur and a +/// FO-local CSS path clip. The FO is outset by stroke reach plus ~2× the +/// design blur radius so the filter has margin to sample. +fn emit_foreign_object( + builder: &mut SvgLayerCanvas, + element: &Shape, + draw_matrix: &skia::Matrix, + clip_path_local: &skia::Path, + stroke_outset: f32, + blur_value: f32, + sigma: f32, +) { + let margin = stroke_outset + 2.0 * blur_value; + let mut local = element.selrect(); + if margin > 0.0 { + local.outset((margin, margin)); + } + + let page = builder.map_selrect_to_page(&local, draw_matrix); + let x = page.left(); + let y = page.top(); + let w = page.width(); + let h = page.height(); + + let local_clip = builder + .map_path_to_page(clip_path_local, draw_matrix) + .make_offset((-x, -y)); + let fill_rule = css_path_fill_rule(local_clip.fill_type()); + let d = skia_path_to_svg_d(&local_clip); + + builder.push_raw(&format!( + concat!( + r#""#, + r#"
"#, + r#"
"# + ), + x = x, + y = y, + w = w, + h = h, + sigma = sigma, + fill_rule = fill_rule, + d = d, + )); +} + +/// CSS `clip-path: path(, …)` keyword for a Skia path fill type. +fn css_path_fill_rule(fill_type: skia::PathFillType) -> &'static str { + match fill_type { + skia::PathFillType::EvenOdd | skia::PathFillType::InverseEvenOdd => "evenodd", + _ => "nonzero", + } +} + +/// Serializes a Skia path to an SVG `d` string for CSS `clip-path: path(...)`. +fn skia_path_to_svg_d(path: &skia::Path) -> String { + let converted = Path::from_skia_path_accurate(path.clone()); + let mut out = String::new(); + for segment in converted.segments() { + match *segment { + Segment::MoveTo((px, py)) => out.push_str(&format!("M{px} {py}")), + Segment::LineTo((px, py)) => out.push_str(&format!("L{px} {py}")), + Segment::CurveTo(((c1x, c1y), (c2x, c2y), (px, py))) => { + out.push_str(&format!("C{c1x} {c1y} {c2x} {c2y} {px} {py}")) + } + Segment::Close => out.push('Z'), + } + } + out.replace('\'', "") +} diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index 8d2cbb059d..2de25b553d 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -188,6 +188,31 @@ impl SvgLayerCanvas { ) } + /// Maps a shape-local rect through the page CTM into SVG user space. + pub(super) fn map_selrect_to_page( + &self, + local: &skia::Rect, + draw_matrix: &skia::Matrix, + ) -> skia::Rect { + self.page_ctm(draw_matrix).map_rect(*local).0 + } + + /// Maps a shape-local path through the page CTM into SVG user space. + pub(super) fn map_path_to_page( + &self, + path: &skia::Path, + draw_matrix: &skia::Matrix, + ) -> skia::Path { + path.make_transform(&self.page_ctm(draw_matrix)) + } + + /// Scale × translate × `draw_matrix` (same CTM as SVG fragment canvases). + fn page_ctm(&self, draw_matrix: &skia::Matrix) -> skia::Matrix { + let mut ctm = skia::Matrix::scale((self.scale, self.scale)); + ctm = ctm * skia::Matrix::translate((self.tx, self.ty)); + ctm * *draw_matrix + } + /// Emits a `` from a shape's geometry (in device/page space). /// /// A mask can be a group too. Since a group has no geometry of its own, we diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs index 8ffcba083f..99fede43b3 100644 --- a/render-wasm/src/render/svg/frames.rs +++ b/render-wasm/src/render/svg/frames.rs @@ -2,6 +2,7 @@ use crate::error::Result; use crate::shapes::{Shadow, Shape}; use crate::state::ShapesPoolRef; +use super::background_blur::emit_background_blur; use super::document::{ content_effect_attrs, opacity_blend_attrs, push_container_drop_filter, shape_with_selrect_outset, SvgLayerCanvas, @@ -20,6 +21,8 @@ pub(super) fn render_frame( // Frames break SVG fill inheritance (GPU pushes an empty nested_fills entry). builder.nested_fills.push(Vec::new()); + emit_background_blur(builder, element, scale)?; + // Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer // before the shadow composite). let composite = opacity_blend_attrs(element); diff --git a/render-wasm/src/render/svg/groups.rs b/render-wasm/src/render/svg/groups.rs index a57e3dae85..f9d2a3fd16 100644 --- a/render-wasm/src/render/svg/groups.rs +++ b/render-wasm/src/render/svg/groups.rs @@ -3,6 +3,7 @@ use crate::shapes::{Shadow, Shape, Type}; use crate::state::ShapesPoolRef; use crate::uuid::Uuid; +use super::background_blur::emit_background_blur; use super::document::{ content_effect_attrs, opacity_blend_attrs, push_container_drop_filter, SvgLayerCanvas, }; @@ -19,6 +20,8 @@ pub(super) fn render_group( // Group fills inherit to empty-fill children (GPU nested_fills / SVG fill). builder.nested_fills.push(element.fills.clone()); + emit_background_blur(builder, element, scale)?; + // Opacity/blend wrap silhouette + content (GPU opens the opacity save_layer // before the shadow composite). let composite = opacity_blend_attrs(element); diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index 590a66b3ac..54e5f91588 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -61,7 +61,8 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect /// ``. /// /// Layer blur and drop/inner shadows are re-emitted as a native SVG `` -/// wrapper. Masked groups use a native alpha ``. +/// wrapper. Masked groups use a native alpha ``. Background blur uses an +/// XHTML `foreignObject` with CSS `backdrop-filter` (browser rendering). /// Solid Inner/Outer and dotted/dashed strokes go out as filled outlines; /// image-filled strokes use a linked `` clipped to the stroke; /// text strokes use `` / glyph clipPath / inverse glyph mask, and @@ -126,12 +127,14 @@ pub(crate) fn render_tree_to_svg( Ok(out.into_bytes()) } +mod background_blur; mod document; mod frames; mod groups; mod images; mod text; +use background_blur::emit_background_blur; use document::SvgLayerCanvas; use frames::render_frame; use groups::render_group; @@ -199,6 +202,8 @@ fn render_leaf( return render_leaf_text(builder, shared, element, scale); } + emit_background_blur(builder, element, scale)?; + let composite = opacity_blend_attrs(element); if let Some(attrs) = &composite { builder.open_group(attrs); @@ -246,6 +251,8 @@ fn render_leaf_text( element: &Shape, scale: f32, ) -> Result<()> { + emit_background_blur(builder, element, scale)?; + let effects = effect_attrs(builder, element); if let Some(attrs) = &effects { builder.open_group(attrs); @@ -268,7 +275,6 @@ fn render_leaf_text( if effects.is_some() { builder.close_group(); } - let _ = scale; Ok(()) } diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__background_blur_coexists_with_layer_blur_outside_filter.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__background_blur_coexists_with_layer_blur_outside_filter.snap new file mode 100644 index 0000000000..0ded1ffe90 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__background_blur_coexists_with_layer_blur_outside_filter.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__exports_frame_background_blur_before_children.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_frame_background_blur_before_children.snap new file mode 100644 index 0000000000..8fa5d3d8c6 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_frame_background_blur_before_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_group_background_blur_selrect_clip.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_background_blur_selrect_clip.snap new file mode 100644 index 0000000000..ec8c95b0c3 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_group_background_blur_selrect_clip.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__exports_leaf_background_blur_as_foreign_object.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_background_blur_as_foreign_object.snap new file mode 100644 index 0000000000..05e7dd546f --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_background_blur_as_foreign_object.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__exports_text_background_blur_with_glyph_clip.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_background_blur_with_glyph_clip.snap new file mode 100644 index 0000000000..77674058c8 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_text_background_blur_with_glyph_clip.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + +
+ + Glass + +
diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_background_blur.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__skips_hidden_background_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_background_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 20d139a26b..d62d5c22ed 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -86,6 +86,201 @@ fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() { insta::assert_snapshot!(svg); } +#[test] +fn exports_leaf_background_blur_as_foreign_object() { + 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_argb(180, 255, 0, 0), + ); + let blur_value = 10.0; + { + let shape = pool.get_mut(&id).unwrap(); + shape.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, false, blur_value))); + } + + let svg = render(&pool, id); + let expected_sigma = radius_to_sigma(blur_value * 1.0); + assert!( + svg.contains("