✨ 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.
This commit is contained in:
Alejandro Alonso 2026-09-24 15:29:25 +02:00 committed by GitHub
parent 9d7a685f3c
commit e3e75725d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 467 additions and 11 deletions

View File

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

View File

@ -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<skia::Path> = 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#"<foreignObject x="{x}" y="{y}" width="{w}" height="{h}">"#,
r#"<div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur({sigma}px);-webkit-backdrop-filter:blur({sigma}px);clip-path:path({fill_rule},'{d}');height:100%;width:100%"></div>"#,
r#"</foreignObject>"#
),
x = x,
y = y,
w = w,
h = h,
sigma = sigma,
fill_rule = fill_rule,
d = d,
));
}
/// CSS `clip-path: path(<fill-rule>, …)` 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('\'', "")
}

View File

@ -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 `<clipPath>` 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

View File

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

View File

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

View File

@ -61,7 +61,8 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
/// `<clipPath>`.
///
/// Layer blur and drop/inner shadows are re-emitted as a native SVG `<filter>`
/// wrapper. Masked groups use a native alpha `<mask>`.
/// wrapper. Masked groups use a native alpha `<mask>`. 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 `<image>` clipped to the stroke;
/// text strokes use `<g opacity>` / 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(())
}

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="116.856415" height="96.856415" viewBox="0 0 116.856415 96.856415"><defs><filter id="fx0" filterUnits="userSpaceOnUse" x="0" y="0" width="116.856415" height="96.856415" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="bg"/><feBlend mode="normal" in="SourceGraphic" in2="bg" result="shape"/><feGaussianBlur in="shape" stdDeviation="2.809401"/></filter></defs><foreignObject x="-7.5717964" y="-7.5717964" width="132" height="112"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5.118802px);-webkit-backdrop-filter:blur(5.118802px);clip-path:path(nonzero,'M16 16L116 16L116 96L16 96Z');height:100%;width:100%"></div></foreignObject><g filter="url(#fx0)">
<rect fill="#0080FF" fill-opacity="0.70588237" transform="translate(8.4282 8.4282)" width="100" height="80"/>
</g></svg>

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="200" height="120" viewBox="0 0 200 120"><foreignObject x="-12" y="-12" width="224" height="144"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(3.9641016px);-webkit-backdrop-filter:blur(3.9641016px);clip-path:path(nonzero,'M12 12L212 12L212 132L12 132Z');height:100%;width:100%"></div></foreignObject>
<rect fill="white" fill-opacity="0.39215687" width="200" height="120"/>
<rect fill="blue" x="20" y="20" 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="200" height="100" viewBox="0 0 200 100"><foreignObject x="-10" y="-10" width="220" height="120"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(3.3867512px);-webkit-backdrop-filter:blur(3.3867512px);clip-path:path(nonzero,'M10 10L210 10L210 110L10 110Z');height:100%;width:100%"></div></foreignObject>
<rect fill="#00C800" x="10" y="10" width="80" 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="100" height="80" viewBox="0 0 100 80"><foreignObject x="-20" y="-20" width="140" height="120"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(6.2735023px);-webkit-backdrop-filter:blur(6.2735023px);clip-path:path(nonzero,'M20 20L120 20L120 100L20 100Z');height:100%;width:100%"></div></foreignObject>
<rect fill="red" fill-opacity="0.70588237" 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="100" height="80" viewBox="0 0 100 80">
<rect fill="red" width="100" height="80"/>
</svg>

View File

@ -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("<foreignObject") && svg.contains("backdrop-filter:blur("),
"background blur must emit foreignObject + backdrop-filter: {svg}"
);
assert!(
svg.contains(&format!("backdrop-filter:blur({expected_sigma}px)")),
"blur sigma must match radius_to_sigma(value * scale): {svg}"
);
assert!(
svg.contains("clip-path:path(")
&& (svg.contains("path(nonzero,'") || svg.contains("path(evenodd,'")),
"FO div must use CSS path clip with a fill rule: {svg}"
);
// FO before fill paint.
let fo_pos = svg.find("<foreignObject").expect("foreignObject");
let fill_pos = svg.find("fill=").expect("fill");
assert!(
fo_pos < fill_pos,
"background blur FO must precede fills: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn skips_hidden_background_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_background_blur(Some(Blur::new(BlurType::BackgroundBlur, true, 10.0)));
}
let svg = render(&pool, id);
assert!(
!svg.contains("foreignObject") && !svg.contains("backdrop-filter"),
"hidden background blur must not emit FO: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn background_blur_coexists_with_layer_blur_outside_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_argb(180, 0, 128, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, false, 8.0)));
shape.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 4.0)));
}
let svg = render(&pool, id);
let fo_pos = svg.find("<foreignObject").expect("foreignObject");
let filter_pos = svg.find("filter=\"url(#fx").expect("layer blur filter");
assert!(
fo_pos < filter_pos,
"FO must sit outside the layer-blur filter group: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_frame_background_blur_before_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_argb(100, 255, 255, 255),
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, false, 6.0)));
}
add_solid_rect(
&mut pool,
child,
frame_id,
(20.0, 20.0, 80.0, 80.0),
skia::Color::from_rgb(0, 0, 255),
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
let svg = render(&pool, frame_id);
let expected_sigma = radius_to_sigma(6.0);
assert!(
svg.contains(&format!("backdrop-filter:blur({expected_sigma}px)")),
"frame background blur: {svg}"
);
let fo_pos = svg.find("<foreignObject").expect("foreignObject");
// Child rect is painted after the frame FO (and after any frame fill).
let child_marker = "width=\"60\" height=\"60\"";
let child_pos = svg.find(child_marker).expect("child rect size");
assert!(fo_pos < child_pos, "frame FO must precede children: {svg}");
insta::assert_snapshot!(svg);
}
#[test]
fn exports_group_background_blur_selrect_clip() {
let mut pool = ShapesPool::new();
let group_id = uid(1);
let a = uid(2);
add_group(
&mut pool,
group_id,
Uuid::nil(),
(0.0, 0.0, 200.0, 100.0),
&[a],
);
{
let group = pool.get_mut(&group_id).unwrap();
group.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, false, 5.0)));
}
add_solid_rect(
&mut pool,
a,
group_id,
(10.0, 10.0, 90.0, 90.0),
skia::Color::from_rgb(0, 200, 0),
);
let svg = render(&pool, group_id);
assert!(
svg.contains("foreignObject") && svg.contains("clip-path:path("),
"group background blur: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_text_background_blur_with_glyph_clip() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_text(
&mut pool,
id,
(0.0, 0.0, 200.0, 80.0),
"Glass",
40.0,
skia::Color::from_argb(200, 255, 255, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.set_background_blur(Some(Blur::new(BlurType::BackgroundBlur, false, 8.0)));
}
let svg = render(&pool, id);
assert!(
svg.contains("foreignObject")
&& svg.contains("backdrop-filter:blur(")
&& svg.contains("clip-path:path("),
"text background blur needs FO with CSS glyph path clip: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_leaf_layer_blur_as_fe_gaussian_blur() {
let mut pool = ShapesPool::new();