Add basic WASM SVG export path

Introduce a minimal vector SVG exporter via render-wasm: solid fills,
native opacity/blend wrappers, frame clip-content, FFI/frontend wiring,
insta snapshots, and a visual preview gallery. Defer save_layer
workarounds (shadows, blur, masks, special strokes) to follow-up PRs.
This commit is contained in:
Alejandro Alonso 2026-07-28 13:22:35 +02:00
parent 65111195a9
commit b78db68b5d
21 changed files with 1077 additions and 3 deletions

View File

@ -163,7 +163,7 @@
(dom/trigger-download-uri filename mtype resource-uri)))))
;; TODO: Remove once we support WASM SVG export
(def ^:private wasm-export-types #{:jpeg :webp :png :pdf})
(def ^:private wasm-export-types #{:jpeg :webp :png :pdf :svg})
(defn- wasm-export-enabled?
"WASM export is available: the flag is set AND render-wasm is active for the
@ -186,6 +186,7 @@
(effect [_ _ _]
(case (:type export)
:pdf (wasm.exports/export-pdf export)
:svg (wasm.exports/export-svg export)
(wasm.exports/export-image export)))))
(defn request-simple-export

View File

@ -44,3 +44,17 @@
(js/queueMicrotask #(wapi/revoke-uri url))
nil))
(defn export-svg-uri
[{:keys [scale object-id]}]
(let [bytes (wasm.api/render-shape-svg object-id (or scale 1))
blob (wapi/create-blob bytes "image/svg+xml")]
(wapi/create-uri blob)))
(defn export-svg
[{:keys [suffix name] :as params}]
(let [url (export-svg-uri params)
filename (str name (or suffix "") ".svg")]
(dom/trigger-download-uri filename "image/svg+xml" url)
(js/queueMicrotask #(wapi/revoke-uri url))
nil))

View File

@ -2688,6 +2688,24 @@
(mem/free)
result))
(defn render-shape-svg
[shape-id scale]
(let [buffer (uuid/get-u32 shape-id)
offset
(h/call wasm/internal-module "_render_shape_svg"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
scale)
heap (mem/get-heap-u8)
heapu32 (mem/get-heap-u32)
length (aget heapu32 (mem/->offset-32 offset))
result (dr/read-image-bytes heap (+ offset 4) length)]
(mem/free)
result))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")

70
render-wasm/Cargo.lock generated
View File

@ -99,6 +99,17 @@ dependencies = [
"libloading",
]
[[package]]
name = "console"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "crc32fast"
version = "1.4.2"
@ -114,6 +125,12 @@ version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "equivalent"
version = "1.0.1"
@ -130,6 +147,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "filetime"
version = "0.2.25"
@ -222,6 +245,18 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "insta"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
dependencies = [
"console",
"once_cell",
"similar",
"tempfile",
]
[[package]]
name = "itertools"
version = "0.12.1"
@ -426,6 +461,7 @@ dependencies = [
"gl",
"glam",
"indexmap",
"insta",
"macros",
"skia-safe",
"thiserror",
@ -519,6 +555,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "skia-bindings"
version = "0.93.1"
@ -582,6 +624,19 @@ dependencies = [
"xattr",
]
[[package]]
name = "tempfile"
version = "3.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b"
dependencies = [
"cfg-if",
"fastrand",
"once_cell",
"rustix",
"windows-sys 0.59.0",
]
[[package]]
name = "thiserror"
version = "2.0.18"
@ -721,6 +776,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
@ -739,6 +800,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"

View File

@ -37,6 +37,9 @@ skia-safe = { version = "0.93.1", default-features = false, features = [
thiserror = "2.0.18"
uuid = { version = "1.11.0", features = ["v4", "js"] }
[dev-dependencies]
insta = "1"
[profile.release]
opt-level = 3
lto = "fat"

145
render-wasm/preview-snapshots Executable file
View File

@ -0,0 +1,145 @@
#!/usr/bin/env bash
#
# Visual review helper for the SVG exporter's `insta` snapshots.
#
# This script renders the insta snapshots into an HTML gallery
# so you can judge whether a change is *visually* valid
# before accepting it.
#
# Usage:
# ./preview-snapshots # build the gallery and print its path
# ./preview-snapshots --open # also open it in the default browser
#
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text.
#
# When a test produced a pending change there will be a `*.snap.new` next to the
# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side.
# Once a change looks correct, accept it (rename `*.snap.new` -> `*.snap`, or
# `cargo insta accept`) and re-run the tests.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# `mod tests;` lives as `svg/tests.rs`, so insta writes under `svg/snapshots/`.
SNAP_DIR="$SCRIPT_DIR/src/render/svg/snapshots"
OUT_DIR="$SCRIPT_DIR/target/svg-preview"
OUT="$OUT_DIR/index.html"
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
FONT_DIR="$OUT_DIR/fonts"
mkdir -p "$OUT_DIR"
mkdir -p "$FONT_DIR"
cp "$FONT_SRC" "$FONT_DIR/"
# Prints the SVG body of a snapshot file: everything after the second `---`
# line (the YAML front matter insta writes).
extract_body() {
awk 'd>=2{print} /^---$/{d++}' "$1"
}
# Embeds a snapshot's SVG via <object> pointing at a standalone .svg file.
#
# Snapshots must NOT be inlined together into one HTML document: each SVG
# restarts its def ids at `clip0`/`blur0`/..., so inlining several in the same
# document makes `url(#clip0)` collide and resolve to the wrong (or empty) clip
# path. Writing each SVG to its own file isolates ids per document.
# We use <object> (not <img>) so linked @font-face rules and other external
# resources resolve inside the SVG document.
svg_counter=0
read_svg_dims() {
local file="$1"
local tag
tag=$(grep -m1 '<svg' "$file" | sed 's/>.*//')
SVG_W=$(echo "$tag" | sed -n 's/.*[[:space:]]width="\([^"]*\)".*/\1/p')
SVG_H=$(echo "$tag" | sed -n 's/.*[[:space:]]height="\([^"]*\)".*/\1/p')
}
emit_svg_box() {
local body_file="$OUT_DIR/svg-$svg_counter.svg"
local name
svg_counter=$((svg_counter + 1))
name="$(basename "$body_file")"
extract_body "$1" > "$body_file"
read_svg_dims "$body_file"
echo "<div class=\"cb\"><object data=\"$name\" type=\"image/svg+xml\" width=\"$SVG_W\" height=\"$SVG_H\"></object></div>"
}
{
cat <<'HTML'
<!doctype html>
<meta charset="utf-8">
<title>render-wasm SVG snapshot preview</title>
<style>
:root { color-scheme: light dark; }
body { font-family: system-ui, sans-serif; margin: 24px; }
h1 { font-size: 20px; }
section { border: 1px solid #8884; border-radius: 8px; padding: 12px 16px; margin: 16px 0; }
h2 { font-size: 14px; font-family: monospace; margin: 0 0 8px; word-break: break-all; }
.cols { display: flex; gap: 24px; flex-wrap: wrap; }
figure { margin: 0; }
figcaption { font-size: 12px; color: #888; margin-bottom: 4px; }
/* Checkerboard so transparency / opacity / blend are visible. */
.cb {
display: inline-block;
line-height: 0;
background-image:
linear-gradient(45deg, #ccc 25%, transparent 25%),
linear-gradient(-45deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(-45deg, transparent 75%, #ccc 75%);
background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
border: 1px solid #8884;
}
.cb object {
display: block;
}
.changed { color: #c60; font-weight: bold; font-size: 13px; }
details { margin-top: 8px; }
pre { background: #8881; padding: 8px; overflow: auto; font-size: 12px; }
</style>
<h1>render-wasm SVG snapshot preview</h1>
HTML
shopt -s nullglob
for snap in "$SNAP_DIR"/*.snap; do
name="$(basename "$snap" .snap)"
new="$snap.new"
echo "<section><h2>$name</h2>"
if [ -f "$new" ]; then
echo '<p class="changed">Pending change: review before accepting</p>'
echo '<div class="cols">'
echo '<figure><figcaption>accepted (current .snap)</figcaption>'
emit_svg_box "$snap"
echo '</figure>'
echo '<figure><figcaption>new (.snap.new)</figcaption>'
emit_svg_box "$new"
echo '</figure>'
echo '</div>'
echo '<details><summary>text diff</summary><pre>'
diff -u "$snap" "$new" | sed 's/&/\&amp;/g; s/</\&lt;/g; s/>/\&gt;/g' || true
echo '</pre></details>'
else
emit_svg_box "$snap"
fi
echo '</section>'
done
# New tests whose snapshot has never been accepted yet.
for new in "$SNAP_DIR"/*.snap.new; do
base="${new%.new}"
[ -f "$base" ] && continue
name="$(basename "$new" .snap.new)"
echo "<section><h2>$name</h2>"
echo '<p class="changed">new snapshot (no accepted version yet)</p>'
emit_svg_box "$new"
echo '</section>'
done
} > "$OUT"
echo "Wrote $OUT"
if [ "${1:-}" = "--open" ]; then
xdg-open "$OUT" >/dev/null 2>&1 || open "$OUT" >/dev/null 2>&1 || true
fi

View File

@ -1037,6 +1037,22 @@ pub extern "C" fn render_shape_pdf(a: u32, b: u32, c: u32, d: u32, scale: f32) -
})
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_shape_svg(a: u32, b: u32, c: u32, d: u32, scale: f32) -> Result<*mut u8> {
let id = uuid_from_u32_quartet(a, b, c, d);
with_state!(state, {
let data = state.render_shape_svg(&id, scale)?;
let len = data.len() as u32;
let mut buf = Vec::with_capacity(4 + data.len());
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&data);
Ok(mem::write_bytes(buf))
})
}
/// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]`
/// (LE), same layout as `render_shape_pixels`. `format` selects the encoder:
/// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`).

View File

@ -13,6 +13,7 @@ mod shadows;
pub mod shape_renderer;
mod strokes;
mod surfaces;
pub mod svg;
pub mod text;
pub mod text_editor;
mod ui;

View File

@ -0,0 +1,238 @@
use skia_safe::{self as skia, Paint};
use crate::shapes::{Shape, Type};
use crate::state::ShapesPoolRef;
use crate::render::vector::draw_shape_geometry;
// Skia's SVG backend (`SkSVGDevice`) silently drops everything drawn inside a
// `save_layer`, so composite effects rendered with `save_layer` (opacity,
// blend mode, …) vanish in SVG.
//
// Instead of one canvas, the SVG path composes the document itself: leaf
// content is drawn into short-lived `skia::svg::Canvas` fragments (real
// `<path>`/`<text>`/… vector markup), and composite effects become native SVG
// `<g>` wrappers (`opacity`, `mix-blend-mode`, `clip-path`).
/// Accumulates the SVG document body while drawing.
pub(crate) struct SvgLayerCanvas {
pub(super) scale: f32,
page_rect: skia::Rect,
tx: f32,
ty: f32,
pub(super) out: String,
pub(super) defs: String,
pending: Option<skia::svg::Canvas>,
next_id: usize,
frag_no: usize,
}
impl SvgLayerCanvas {
pub(super) fn new(scale: f32, page_rect: skia::Rect, tx: f32, ty: f32) -> Self {
Self {
scale,
page_rect,
tx,
ty,
out: String::new(),
defs: String::new(),
pending: None,
next_id: 0,
frag_no: 0,
}
}
pub(super) fn unique(&mut self, prefix: &str) -> String {
let id = format!("{prefix}{}", self.next_id);
self.next_id += 1;
id
}
/// Creates a fragment canvas configured with the page transform
/// (scale + translate to the export bounds).
pub(super) fn new_fragment(&self) -> skia::svg::Canvas {
let canvas = skia::svg::Canvas::new(self.page_rect, None);
{
let cv: &skia::Canvas = &canvas;
cv.scale((self.scale, self.scale));
cv.translate((self.tx, self.ty));
}
canvas
}
/// Returns the current leaf-drawing canvas, creating a fragment if needed.
pub(crate) fn canvas(&mut self) -> &skia::Canvas {
if self.pending.is_none() {
self.pending = Some(self.new_fragment());
}
self.pending.as_deref().unwrap()
}
/// Finalizes the pending fragment and appends its markup to `out`.
pub(super) fn flush(&mut self) {
let Some(canvas) = self.pending.take() else {
return;
};
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
if inner.trim().is_empty() {
return;
}
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
self.out.push_str(&remap_ids(inner, &prefix));
}
pub(super) fn open_group(&mut self, attrs: &str) {
self.flush();
self.out.push_str("<g ");
self.out.push_str(attrs);
self.out.push('>');
}
pub(super) fn close_group(&mut self) {
self.flush();
self.out.push_str("</g>");
}
/// 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
/// recurse into its descendants and accumulate their geometry.
pub(super) fn push_clip_path(&mut self, id: &str, shape: &Shape, tree: ShapesPoolRef) {
let canvas = self.new_fragment();
{
let cv: &skia::Canvas = &canvas;
let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_color(skia::Color::BLACK);
draw_clip_geometry(cv, shape, tree, &paint);
}
self.finish_clip_path_fragment(id, canvas);
}
/// Finalizes a fragment canvas as a `<clipPath>` def.
pub(super) fn finish_clip_path_fragment(&mut self, id: &str, canvas: skia::svg::Canvas) {
let data = canvas.end();
let doc = String::from_utf8_lossy(data.as_bytes());
let inner = extract_inner_svg(&doc);
let prefix = format!("f{}_", self.frag_no);
self.frag_no += 1;
let geometry = remap_ids(inner, &prefix);
self.defs.push_str(&format!(
"<clipPath id=\"{id}\" clipPathUnits=\"userSpaceOnUse\">{geometry}</clipPath>"
));
}
}
/// Draws a clip geometry into `cv` (already set up with the page transform).
fn draw_clip_geometry(cv: &skia::Canvas, shape: &Shape, tree: ShapesPoolRef, paint: &Paint) {
if let Type::Group(_) = &shape.shape_type {
for child_id in shape.children_ids_iter_forward(true) {
if let Some(child) = tree.get(child_id) {
draw_clip_geometry(cv, child, tree, paint);
}
}
return;
}
cv.save();
cv.concat(&shape.centered_transform());
draw_shape_geometry(cv, shape, paint);
cv.restore();
}
/// Builds the `<g>` attribute string for a shape's composite effects (opacity,
/// blend mode). 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> {
let mut parts: Vec<String> = Vec::new();
let opacity = element.opacity();
if opacity < 1.0 {
parts.push(format!("opacity=\"{opacity}\""));
}
if let Some(css) = blend_css(element.blend_mode().0) {
parts.push(format!("style=\"mix-blend-mode:{css}\""));
}
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
/// Maps a Skia blend mode to its CSS `mix-blend-mode` keyword. Returns `None`
/// for `SrcOver` (normal) and modes without a CSS equivalent.
fn blend_css(mode: skia::BlendMode) -> Option<&'static str> {
use skia::BlendMode::*;
Some(match mode {
Multiply => "multiply",
Screen => "screen",
Overlay => "overlay",
Darken => "darken",
Lighten => "lighten",
ColorDodge => "color-dodge",
ColorBurn => "color-burn",
HardLight => "hard-light",
SoftLight => "soft-light",
Difference => "difference",
Exclusion => "exclusion",
Hue => "hue",
Saturation => "saturation",
Color => "color",
Luminosity => "luminosity",
_ => return None,
})
}
/// Returns the inner body of a Skia SVG document (everything between the
/// opening `<svg …>` tag and the closing `</svg>`).
fn extract_inner_svg(doc: &str) -> &str {
let start = doc
.find("<svg")
.and_then(|s| doc[s..].find('>').map(|e| s + e + 1));
let end = doc.rfind("</svg>");
match (start, end) {
(Some(s), Some(e)) if s <= e => &doc[s..e],
_ => "",
}
}
/// Prefixes every id defined in a fragment (and its `url(#…)` / `#…`
/// references) so ids stay unique once fragments are merged into one document.
fn remap_ids(body: &str, prefix: &str) -> String {
let needle = "id=\"";
let mut ids: Vec<&str> = Vec::new();
let mut offset = 0;
while let Some(pos) = body[offset..].find(needle) {
let start = offset + pos + needle.len();
let Some(end_rel) = body[start..].find('"') else {
break;
};
let id = &body[start..start + end_rel];
if !id.is_empty() {
ids.push(id);
}
offset = start + end_rel + 1;
}
ids.sort_unstable();
ids.dedup();
// Longest-first so a shorter id can't collide inside a longer one.
ids.sort_by_key(|b| std::cmp::Reverse(b.len()));
let mut out = body.to_string();
for id in ids {
let new_id = format!("{prefix}{id}");
out = out.replace(&format!("id=\"{id}\""), &format!("id=\"{new_id}\""));
out = out.replace(&format!("url(#{id})"), &format!("url(#{new_id})"));
out = out.replace(&format!("=\"#{id}\""), &format!("=\"#{new_id}\""));
}
out
}

View File

@ -0,0 +1,71 @@
//! GPU-free scene builders and render helpers for SVG export tests.
use skia_safe as skia;
use crate::render::RenderResources;
use crate::shapes::{Fill, Frame, Group, Rect, SolidColor, Type};
use crate::state::ShapesPool;
use crate::utils::uuid_from_u32_quartet;
use crate::uuid::Uuid;
use super::render_tree_to_svg;
/// Deterministic UUID from a small integer, keeping snapshots stable.
pub(super) fn uid(n: u32) -> Uuid {
uuid_from_u32_quartet(0, 0, 0, n)
}
/// Adds a solid-filled rectangle to the pool.
pub(super) fn add_solid_rect(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
color: skia::Color,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Rect(Rect::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
}
/// Adds a solid-filled frame (board) to the pool.
pub(super) fn add_frame(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
color: skia::Color,
clip: bool,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Frame(Frame::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
shape.set_clip(clip);
}
/// Adds an empty (unmasked) group.
pub(super) fn add_group(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
children: &[Uuid],
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Group(Group { masked: false }));
shape.set_selrect(l, t, r, b);
for child in children {
shape.add_child(*child);
}
}
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
String::from_utf8(bytes).expect("utf8 svg")
}

View File

@ -0,0 +1,66 @@
use crate::error::Result;
use crate::render::shape_renderer::ShapeRenderer;
use crate::render::vector::VectorRenderer;
use crate::shapes::{Shape, Stroke};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::render_tree;
use crate::render::RenderResources;
pub(super) fn render_frame(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let matrix = element.centered_transform();
let effects = effect_attrs(element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
let clipped = element.clip_content;
if clipped {
let clip_id = builder.unique("clip");
builder.push_clip_path(&clip_id, element, tree);
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
// Frame background (frame space).
if !element.fills.is_empty() {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
renderer.draw_fills(element, &element.fills)?;
canvas.restore();
}
// Children (absolute coords).
let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect();
for child_id in &children {
render_tree(builder, shared, child_id, tree, scale)?;
}
// Strokes over children (frame space).
let visible_strokes: Vec<&Stroke> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
renderer.draw_strokes(element, &visible_strokes)?;
canvas.restore();
}
if clipped {
builder.close_group();
}
if effects.is_some() {
builder.close_group();
}
Ok(())
}

View File

@ -0,0 +1,35 @@
use crate::error::Result;
use crate::shapes::Shape;
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::render_tree;
use crate::render::RenderResources;
pub(super) fn render_group(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
// Masked groups are deferred: they need an alpha `<mask>` compositor that
// will land in a later PR. For now we still emit the full child list
// (including the mask shape as normal content) so basic group opacity
// keeps working.
let children: Vec<_> = element.children_ids_iter_forward(false).copied().collect();
for child_id in &children {
render_tree(builder, shared, child_id, tree, scale)?;
}
if effects.is_some() {
builder.close_group();
}
Ok(())
}

View File

@ -0,0 +1,167 @@
use skia_safe::{self as skia};
use crate::error::Result;
use crate::shapes::Type;
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::vector::{render_leaf_content, VectorRenderer};
use super::RenderResources;
/// 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>`.
///
/// Special-case re-emission for shadows, layer blur, masks, and deferred
/// strokes is intentionally out of scope for this first cut.
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 = shape.extrect(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 out = String::with_capacity(body.len() + defs.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}\">"
));
if !defs.is_empty() {
out.push_str("<defs>");
out.push_str(&defs);
out.push_str("</defs>");
}
out.push_str(&body);
out.push_str("</svg>");
Ok(out.into_bytes())
}
mod document;
mod frames;
mod groups;
use document::SvgLayerCanvas;
use frames::render_frame;
use groups::render_group;
use crate::shapes::Shape;
use document::effect_attrs;
/// 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, scale),
}
}
fn render_leaf(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(element);
if let Some(attrs) = &effects {
builder.open_group(attrs);
}
{
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale);
render_leaf_content(&mut renderer, element)?;
canvas.restore();
}
if effects.is_some() {
builder.close_group();
}
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;

View File

@ -0,0 +1,11 @@
---
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="100"><defs><clipPath id="clip0" clipPathUnits="userSpaceOnUse">
<rect width="100" height="100"/>
</clipPath></defs><g clip-path="url(#clip0)">
<rect fill="#F0F0F0" width="100" height="100"/>
<rect fill="red" x="50" y="50" width="100" height="100"/>
</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="100"><g opacity="0.7">
<rect fill="blue" width="90" height="100"/>
<rect fill="#00C800" x="110" width="90" height="100"/>
</g></svg>

View File

@ -0,0 +1,8 @@
---
source: src/render/svg/tests.rs
expression: "render(&pool, id)"
---
<?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">
<rect fill="red" width="100" height="80"/>
</svg>

View File

@ -0,0 +1,10 @@
---
source: src/render/svg/tests.rs
assertion_line: 175
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="150" height="150">
<rect fill="#F0F0F0" width="100" height="100"/>
<rect fill="red" x="50" y="50" width="100" height="100"/>
</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="100"><g opacity="0.5" style="mix-blend-mode:multiply">
<rect fill="#0080FF" width="100" height="100"/>
</g></svg>

View File

@ -0,0 +1,176 @@
use super::fixtures::*;
use crate::shapes::BlendMode;
use crate::state::ShapesPool;
use crate::uuid::Uuid;
use skia_safe as skia;
#[test]
fn exports_a_solid_rect() {
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),
);
insta::assert_snapshot!(render(&pool, id));
}
#[test]
fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() {
let mut pool = ShapesPool::new();
let id = uid(1);
add_solid_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 100.0),
skia::Color::from_rgb(0, 128, 255),
);
{
let shape = pool.get_mut(&id).unwrap();
shape.set_opacity(0.5);
shape.set_blend_mode(BlendMode(skia::BlendMode::Multiply));
}
let svg = render(&pool, id);
assert!(
svg.contains("opacity=\"0.5\""),
"missing opacity wrapper: {svg}"
);
assert!(
svg.contains("mix-blend-mode:multiply"),
"missing blend-mode wrapper: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_group_with_two_rects_and_group_opacity() {
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_opacity(0.7);
}
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);
assert!(
svg.contains("opacity=\"0.7\""),
"missing group opacity wrapper: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_a_clipped_frame_with_overflowing_child() {
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, 100.0, 100.0),
skia::Color::from_rgb(240, 240, 240),
true,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
// Child extends past the frame's right/bottom edge.
add_solid_rect(
&mut pool,
child,
frame_id,
(50.0, 50.0, 150.0, 150.0),
skia::Color::from_rgb(255, 0, 0),
);
let svg = render(&pool, frame_id);
assert!(
svg.contains("clip-path=\"url(#"),
"missing frame clip-path wrapper: {svg}"
);
assert!(svg.contains("<clipPath "), "missing clipPath def: {svg}");
// Clipped boards keep the frame's own page size.
assert!(
svg.contains("width=\"100\" height=\"100\""),
"clipped frame should export at selrect size: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_an_unclipped_frame_with_overflowing_child() {
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, 100.0, 100.0),
skia::Color::from_rgb(240, 240, 240),
false,
);
{
let frame = pool.get_mut(&frame_id).unwrap();
frame.add_child(child);
}
// Child extends past the frame's right/bottom edge.
add_solid_rect(
&mut pool,
child,
frame_id,
(50.0, 50.0, 150.0, 150.0),
skia::Color::from_rgb(255, 0, 0),
);
let svg = render(&pool, frame_id);
assert!(
!svg.contains("clip-path=\"url(#"),
"unclipped frame must not emit clip-path: {svg}"
);
// Page must grow to include the overflowing child (0..150).
assert!(
svg.contains("width=\"150\" height=\"150\""),
"unclipped frame should export at extrect size: {svg}"
);
insta::assert_snapshot!(svg);
}

View File

@ -933,7 +933,10 @@ fn render_leaf(
/// Single source of truth for leaf content draw order/gating (fills, inner
/// shadows, strokes), generic over [`ShapeRenderer`]. Drop shadows and layer
/// blur are excluded — they wrap the content and are sequenced per backend.
fn render_leaf_content<R: ShapeRenderer + ?Sized>(renderer: &mut R, shape: &Shape) -> Result<()> {
pub(super) fn render_leaf_content<R: ShapeRenderer + ?Sized>(
renderer: &mut R,
shape: &Shape,
) -> Result<()> {
match &shape.shape_type {
Type::Text(_) => renderer.draw_text(shape)?,
Type::SVGRaw(_) => renderer.draw_svg(shape)?,
@ -1173,7 +1176,7 @@ fn transformed_skia_path(shape: &Shape) -> Option<skia::Path> {
// ---------------------------------------------------------------------------
/// Draws the shape's geometry (rect/rrect/oval/path) with the given paint.
fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) {
pub(super) fn draw_shape_geometry(canvas: &Canvas, shape: &Shape, paint: &Paint) {
match &shape.shape_type {
Type::Rect(_) | Type::Frame(_) => {
if let Some(corners) = shape.shape_type.corners() {

View File

@ -105,6 +105,10 @@ impl State {
crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale)
}
pub fn render_shape_svg(&mut self, id: &Uuid, scale: f32) -> Result<Vec<u8>> {
crate::render::svg::render_to_svg(get_resources(), id, &self.shapes, scale)
}
/// GPU-free counterpart of [`State::render_shape_pixels`]: encodes to
/// `format` on a CPU raster surface, no GPU/WebGL.
pub fn render_shape_raster(