mirror of
https://github.com/penpot/penpot.git
synced 2026-09-11 22:49:30 +00:00
✨ Export layer blur to WASM SVG (#11580)
SkSVGDevice drops paint image-filters, so re-emit visible layer blur as a native feGaussianBlur filter on the composite <g>. Match canvas sigma via radius_to_sigma(value * scale), and skip Skia blur filters on the SVG VectorRenderer path so shapes do not vanish. Closes #11380
This commit is contained in:
parent
30849babcc
commit
dbe5941a23
@ -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 {
|
||||
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
|
||||
));
|
||||
}
|
||||
|
||||
/// Registers a layer-blur `<filter>` 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!(
|
||||
"<filter id=\"{id}\" x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\" ",
|
||||
"color-interpolation-filters=\"sRGB\">",
|
||||
"<feGaussianBlur stdDeviation=\"{sigma}\"/>",
|
||||
"</filter>"
|
||||
),
|
||||
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 `<g>` 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<String> {
|
||||
/// Layer blur is a native SVG `<filter>` (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<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
let opacity = element.opacity();
|
||||
@ -187,6 +206,13 @@ pub(super) fn effect_attrs(element: &Shape) -> Option<String> {
|
||||
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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -60,9 +60,10 @@ fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect
|
||||
/// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native
|
||||
/// `<clipPath>`.
|
||||
///
|
||||
/// 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 `<image>` 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 `<image>` 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);
|
||||
}
|
||||
|
||||
@ -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="223.7846" height="123.78461" viewBox="0 0 223.7846 123.78461"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="3.9641016"/></filter></defs><g filter="url(#blur0)">
|
||||
<rect fill="blue" transform="translate(11.8923 11.8923)" width="90" height="100"/>
|
||||
<rect fill="#00C800" transform="translate(11.8923 11.8923)" x="110" width="90" height="100"/>
|
||||
</g></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="137.64102" height="117.64102" viewBox="0 0 137.64102 117.64102"><defs><filter id="blur0" x="-50%" y="-50%" width="200%" height="200%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="6.2735023"/></filter></defs><g filter="url(#blur0)">
|
||||
<rect fill="red" transform="translate(18.8205 18.8205)" width="100" height="80"/>
|
||||
</g></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">
|
||||
<rect fill="red" width="100" height="80"/>
|
||||
</svg>
|
||||
@ -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("<filter") && svg.contains("feGaussianBlur"),
|
||||
"layer blur must emit an SVG filter: {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")),
|
||||
"stdDeviation must match canvas radius_to_sigma(value * scale): {svg}"
|
||||
);
|
||||
assert!(
|
||||
svg.contains("filter=\"url(#blur"),
|
||||
"shape group must reference the blur filter: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_hidden_layer_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_blur(Some(Blur::new(BlurType::LayerBlur, true, 10.0)));
|
||||
}
|
||||
|
||||
let svg = render(&pool, id);
|
||||
assert!(
|
||||
!svg.contains("feGaussianBlur") && !svg.contains("filter=\"url(#blur"),
|
||||
"hidden layer blur must not emit a filter: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_group_layer_blur_wrapping_children() {
|
||||
let mut pool = ShapesPool::new();
|
||||
let group_id = uid(1);
|
||||
let a = uid(2);
|
||||
let b = uid(3);
|
||||
|
||||
add_group(
|
||||
&mut pool,
|
||||
group_id,
|
||||
Uuid::nil(),
|
||||
(0.0, 0.0, 200.0, 100.0),
|
||||
&[a, b],
|
||||
);
|
||||
{
|
||||
let group = pool.get_mut(&group_id).unwrap();
|
||||
group.set_blur(Some(Blur::new(BlurType::LayerBlur, false, 6.0)));
|
||||
}
|
||||
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
a,
|
||||
group_id,
|
||||
(0.0, 0.0, 90.0, 100.0),
|
||||
skia::Color::from_rgb(0, 0, 255),
|
||||
);
|
||||
add_solid_rect(
|
||||
&mut pool,
|
||||
b,
|
||||
group_id,
|
||||
(110.0, 0.0, 200.0, 100.0),
|
||||
skia::Color::from_rgb(0, 200, 0),
|
||||
);
|
||||
|
||||
let svg = render(&pool, group_id);
|
||||
let expected_sigma = radius_to_sigma(6.0);
|
||||
assert!(
|
||||
svg.contains(&format!("stdDeviation=\"{expected_sigma}\"")),
|
||||
"group layer blur stdDeviation: {svg}"
|
||||
);
|
||||
// Filter wrapper must open before child geometry.
|
||||
let filter_pos = svg
|
||||
.find("filter=\"url(#blur")
|
||||
.expect("group filter wrapper");
|
||||
let child_pos = svg.find("fill=\"#").expect("child fill");
|
||||
assert!(
|
||||
filter_pos < child_pos,
|
||||
"group blur must wrap children: {svg}"
|
||||
);
|
||||
insta::assert_snapshot!(svg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_a_group_with_two_rects_and_group_opacity() {
|
||||
let mut pool = ShapesPool::new();
|
||||
|
||||
@ -44,6 +44,18 @@ impl<'a> 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 `<filter>` wrapper instead.
|
||||
fn layer_blur_filter(&self, shape: &Shape) -> Option<skia::ImageFilter> {
|
||||
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(
|
||||
|
||||
@ -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<Blur> {
|
||||
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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user