Alejandro Alonso e3e75725d4
✨ 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.
2026-09-24 15:29:25 +02:00

347 lines
11 KiB
Rust

use skia_safe::{self as skia};
use std::collections::HashSet;
use crate::error::Result;
use crate::math::Bounds;
use crate::shapes::{Shape, Type};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::shape_renderer::ShapeRenderer;
use super::vector::VectorRenderer;
use super::RenderResources;
/// Collects the registered font aliases used by every text span in the subtree
/// rooted at `id`, so the exporter can emit `@font-face` rules for them.
fn collect_font_aliases(tree: ShapesPoolRef, id: &Uuid, out: &mut HashSet<String>) {
let Some(shape) = tree.get(id) else {
return;
};
if let Type::Text(_) = &shape.shape_type {
for paragraph in shape.get_text_content().paragraphs() {
for span in paragraph.children() {
out.insert(format!("{}", span.font_family));
}
}
}
for child_id in shape.children_ids_iter_forward(true) {
collect_font_aliases(tree, child_id, out);
}
}
/// Bounds for the root `<svg>` width, height, and viewBox.
///
/// Text uses [`Shape::layer_bounds`]: glyph metrics, at least the `selrect`, and
/// room for strokes/shadows/blur. Other shapes use `extrect` for overflow.
fn svg_page_bounds(shape: &Shape, tree: ShapesPoolRef, scale: f32) -> skia::Rect {
if matches!(shape.shape_type, Type::Text(_)) {
let mut bounds = Bounds::from_rect(&shape.layer_bounds());
if !shape.transform.is_identity() {
let mut matrix = shape.transform;
let center = shape.center();
matrix.post_translate(center);
matrix.pre_translate(-center);
bounds.transform_mut(&matrix);
}
bounds.to_rect()
} else {
shape.extrect(tree, scale)
}
}
/// Renders a shape tree to an SVG document and returns the raw SVG bytes.
///
/// Dedicated vector-SVG render path. Leaf content (paths, fills, …) is emitted
/// as real SVG markup via short-lived Skia SVG canvases, while composite
/// effects that `SkSVGDevice` would drop (`save_layer` opacity / blend) are
/// composed as native SVG `<g>` wrappers. Frame `clip content` uses a native
/// `<clipPath>`.
///
/// Layer blur and drop/inner shadows are re-emitted as a native SVG `<filter>`
/// 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
/// image-filled text strokes use a linked `<image>` under a stroke luminance mask.
pub fn render_to_svg(
shared: &mut RenderResources,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
) -> Result<Vec<u8>> {
render_tree_to_svg(shared, id, tree, scale)
}
/// Core SVG export, kept as a separate entry so headless native tests can call
/// it with a GPU-free [`RenderResources`].
pub(crate) fn render_tree_to_svg(
shared: &mut RenderResources,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
) -> Result<Vec<u8>> {
let shape = tree
.get(id)
.ok_or_else(|| crate::error::Error::CriticalError("Shape not found for SVG".to_string()))?;
// Use the extended rect so unclipped frames grow to include overflowing
// children (and leaf effects like shadows when those land). Clipped frames
// still resolve to roughly their selrect because `extrect` skips joining
// children when `clip_content` is on.
let bounds = svg_page_bounds(shape, tree, scale);
let page_w = bounds.width() * scale;
let page_h = bounds.height() * scale;
let rect = skia::Rect::from_xywh(0., 0., page_w, page_h);
let (defs, body) = render_body(shared, id, tree, scale, rect, -bounds.left(), -bounds.top())?;
let mut aliases = HashSet::new();
collect_font_aliases(tree, id, &mut aliases);
let font_css = shared.fonts.font_face_css_for_aliases(&aliases);
let mut out = String::with_capacity(body.len() + defs.len() + font_css.len() + 256);
out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n");
out.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \
width=\"{page_w}\" height=\"{page_h}\" viewBox=\"0 0 {page_w} {page_h}\">"
));
if !font_css.is_empty() || !defs.is_empty() {
out.push_str("<defs>");
if !font_css.is_empty() {
out.push_str(&format!(
"<style type=\"text/css\"><![CDATA[{font_css}]]></style>"
));
}
out.push_str(&defs);
out.push_str("</defs>");
}
out.push_str(&body);
out.push_str("</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;
use text::{render_text_fill, render_text_strokes};
use crate::shapes::Shadow;
use document::{
content_effect_attrs, effect_attrs, opacity_blend_attrs, push_container_drop_filter,
push_text_silhouette_spread_filter, shape_with_selrect_outset,
};
use images::{emit_fills, emit_strokes};
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
fn render_body(
shared: &mut RenderResources,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
page_rect: skia::Rect,
tx: f32,
ty: f32,
) -> Result<(String, String)> {
let mut builder = SvgLayerCanvas::new(scale, page_rect, tx, ty);
render_tree(&mut builder, shared, id, tree, scale)?;
builder.flush();
Ok((builder.defs, builder.out))
}
fn render_tree(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
id: &Uuid,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let Some(element) = tree.get(id) else {
return Ok(());
};
if element.hidden {
return Ok(());
}
match &element.shape_type {
Type::Group(_) => render_group(builder, shared, element, tree, scale),
Type::Frame(_) => render_frame(builder, shared, element, tree, scale),
Type::Rect(_)
| Type::Circle
| Type::Path(_)
| Type::Bool(_)
| Type::Text(_)
| Type::SVGRaw(_) => render_leaf(builder, shared, element, tree, scale),
}
}
fn render_leaf(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
// Text keeps the classic leaf filter graph (GPU dilates glyph alpha). Geometry
// leaves match container drops: geometric offset+spread silhouette, then content.
if matches!(element.shape_type, Type::Text(_)) {
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);
}
// Own drop silhouettes: GPU paints geometric spread/offset (avoids
// feMorphology rounding on circles). Filter only blurs/tints.
if !builder.suppress_filters {
let drops: Vec<Shadow> = element.drop_shadows_visible().copied().collect();
for shadow in &drops {
let id = push_container_drop_filter(builder, shadow);
builder.open_group(&format!("filter=\"url(#{id})\""));
let prev_suppress = builder.suppress_filters;
let prev_spread = builder.silhouette_spread;
let prev_offset = builder.silhouette_offset;
builder.suppress_filters = true;
builder.silhouette_spread = shadow.spread;
builder.silhouette_offset = shadow.offset;
render_leaf_geometry(builder, shared, element, tree, scale)?;
builder.silhouette_offset = prev_offset;
builder.silhouette_spread = prev_spread;
builder.suppress_filters = prev_suppress;
builder.close_group();
}
}
let effects = content_effect_attrs(builder, element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
render_leaf_geometry(builder, shared, element, tree, scale)?;
if effects.is_some() {
builder.close_group();
}
if composite.is_some() {
builder.close_group();
}
Ok(())
}
fn render_leaf_text(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
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);
}
let spread = builder.silhouette_spread;
let draw_matrix = builder.silhouette_draw_matrix(element);
// See `push_text_silhouette_spread_filter`: morph-before-blur approx of
// GPU dilate(drop_shadow) for inherited container spread.
let morph_id = push_text_silhouette_spread_filter(builder, spread);
if let Some(id) = &morph_id {
builder.open_group(&format!("filter=\"url(#{id})\""));
}
render_text_fill(builder, shared, element, draw_matrix)?;
render_text_strokes(builder, shared, element, draw_matrix)?;
if morph_id.is_some() {
builder.close_group();
}
if effects.is_some() {
builder.close_group();
}
Ok(())
}
fn render_leaf_geometry(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let spread = builder.silhouette_spread;
// Spread outsets fills only (GPU). Rect/Frame strokes ignore outset.
let fill_shape = shape_with_selrect_outset(element, spread);
// Always from the original element (not outset selrect) so the pivot
// matches content; offset comes from the silhouette pass.
let draw_matrix = builder.silhouette_draw_matrix(element);
if matches!(element.shape_type, Type::SVGRaw(_)) {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&draw_matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_svg(element)?;
canvas.restore();
return Ok(());
}
let fills = builder.effective_fills_owned(&fill_shape);
emit_fills(
builder,
shared,
&fill_shape,
&fills,
tree,
scale,
Some(draw_matrix),
)?;
// Stroke geometry stays on the original selrect (GPU Rect/Frame
// drop-shadow outset is a no-op for single strokes).
let visible_strokes: Vec<_> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
emit_strokes(
builder,
shared,
element,
&visible_strokes,
scale,
Some(draw_matrix),
)?;
}
Ok(())
}
// ===========================================================================
// Tests
// ===========================================================================
//
// Fast, headless native tests (`cargo test --bin render_wasm`) for the SVG
// exporter. They bypass the GPU/browser stack: shapes are built into a
// `ShapesPool` and rendered through [`render_tree_to_svg`] with a headless
// [`RenderResources`]. Output is checked with `insta` snapshots.
//
// To (re)generate snapshots after a deliberate change:
// cargo insta test --accept --bin render_wasm
#[cfg(test)]
mod fixtures;
#[cfg(test)]
mod tests;