mirror of
https://github.com/penpot/penpot.git
synced 2026-09-30 15:56:17 +00:00
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.
347 lines
11 KiB
Rust
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;
|