mirror of
https://github.com/penpot/penpot.git
synced 2026-09-30 07:46:16 +00:00
✨ 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:
parent
9d7a685f3c
commit
e3e75725d4
@ -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);
|
||||
|
||||
169
render-wasm/src/render/svg/background_blur.rs
Normal file
169
render-wasm/src/render/svg/background_blur.rs
Normal 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('\'', "")
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
File diff suppressed because one or more lines are too long
@ -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>
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user