From b78db68b5d1c025d38cc9d63efe74f581c63478a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 28 Jul 2026 13:22:35 +0200 Subject: [PATCH] :sparkles: 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. --- .../src/app/main/data/exports/assets.cljs | 3 +- frontend/src/app/main/data/exports/wasm.cljs | 14 ++ frontend/src/app/render_wasm/api.cljs | 18 ++ render-wasm/Cargo.lock | 70 ++++++ render-wasm/Cargo.toml | 3 + render-wasm/preview-snapshots | 145 +++++++++++ render-wasm/src/main.rs | 16 ++ render-wasm/src/render.rs | 1 + render-wasm/src/render/svg/document.rs | 238 ++++++++++++++++++ render-wasm/src/render/svg/fixtures.rs | 71 ++++++ render-wasm/src/render/svg/frames.rs | 66 +++++ render-wasm/src/render/svg/groups.rs | 35 +++ render-wasm/src/render/svg/mod.rs | 167 ++++++++++++ ..._clipped_frame_with_overflowing_child.snap | 11 + ...roup_with_two_rects_and_group_opacity.snap | 9 + ...der__svg__tests__exports_a_solid_rect.snap | 8 + ...nclipped_frame_with_overflowing_child.snap | 10 + ...city_and_blend_mode_as_group_wrappers.snap | 8 + render-wasm/src/render/svg/tests.rs | 176 +++++++++++++ render-wasm/src/render/vector.rs | 7 +- render-wasm/src/state.rs | 4 + 21 files changed, 1077 insertions(+), 3 deletions(-) create mode 100755 render-wasm/preview-snapshots create mode 100644 render-wasm/src/render/svg/document.rs create mode 100644 render-wasm/src/render/svg/fixtures.rs create mode 100644 render-wasm/src/render/svg/frames.rs create mode 100644 render-wasm/src/render/svg/groups.rs create mode 100644 render-wasm/src/render/svg/mod.rs create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap create mode 100644 render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap create mode 100644 render-wasm/src/render/svg/tests.rs diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 83a7cbb5fa..920cd4dcab 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -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 diff --git a/frontend/src/app/main/data/exports/wasm.cljs b/frontend/src/app/main/data/exports/wasm.cljs index 4cbd5285f8..67dbf081f3 100644 --- a/frontend/src/app/main/data/exports/wasm.cljs +++ b/frontend/src/app/main/data/exports/wasm.cljs @@ -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)) + diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 575a61bb0a..b79bffa21f 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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") diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index 5d749143fd..05fbd20e6a 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -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" diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 15f8f39335..347c101ae4 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -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" diff --git a/render-wasm/preview-snapshots b/render-wasm/preview-snapshots new file mode 100755 index 0000000000..43a23421ca --- /dev/null +++ b/render-wasm/preview-snapshots @@ -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 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 (not ) 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_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 "
" +} + +{ + cat <<'HTML' + + +render-wasm SVG snapshot preview + +

render-wasm SVG snapshot preview

+HTML + + shopt -s nullglob + + for snap in "$SNAP_DIR"/*.snap; do + name="$(basename "$snap" .snap)" + new="$snap.new" + echo "

$name

" + if [ -f "$new" ]; then + echo '

Pending change: review before accepting

' + echo '
' + echo '
accepted (current .snap)
' + emit_svg_box "$snap" + echo '
' + echo '
new (.snap.new)
' + emit_svg_box "$new" + echo '
' + echo '
' + echo '
text diff
'
+            diff -u "$snap" "$new" | sed 's/&/\&/g; s//\>/g' || true
+            echo '
' + else + emit_svg_box "$snap" + fi + echo '
' + 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 "

$name

" + echo '

new snapshot (no accepted version yet)

' + emit_svg_box "$new" + echo '
' + done +} > "$OUT" + +echo "Wrote $OUT" + +if [ "${1:-}" = "--open" ]; then + xdg-open "$OUT" >/dev/null 2>&1 || open "$OUT" >/dev/null 2>&1 || true +fi diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 2d007d18e4..626c5b79a5 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -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`). diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 7ff4423f7a..432ad65aec 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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; diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs new file mode 100644 index 0000000000..5724b61677 --- /dev/null +++ b/render-wasm/src/render/svg/document.rs @@ -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 +// ``/``/… vector markup), and composite effects become native SVG +// `` 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, + 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("'); + } + + pub(super) fn close_group(&mut self) { + self.flush(); + self.out.push_str(""); + } + + /// Emits a `` 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 `` 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!( + "{geometry}" + )); + } +} + +/// 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 `` 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 { + let mut parts: Vec = 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 `` tag and the closing ``). +fn extract_inner_svg(doc: &str) -> &str { + let start = doc + .find("').map(|e| s + e + 1)); + let end = doc.rfind(""); + 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 +} diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs new file mode 100644 index 0000000000..c4cda3cff7 --- /dev/null +++ b/render-wasm/src/render/svg/fixtures.rs @@ -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") +} diff --git a/render-wasm/src/render/svg/frames.rs b/render-wasm/src/render/svg/frames.rs new file mode 100644 index 0000000000..f060a66ce6 --- /dev/null +++ b/render-wasm/src/render/svg/frames.rs @@ -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(()) +} diff --git a/render-wasm/src/render/svg/groups.rs b/render-wasm/src/render/svg/groups.rs new file mode 100644 index 0000000000..5bca7d8876 --- /dev/null +++ b/render-wasm/src/render/svg/groups.rs @@ -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 `` 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(()) +} diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs new file mode 100644 index 0000000000..c4bf54bc7f --- /dev/null +++ b/render-wasm/src/render/svg/mod.rs @@ -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 `` wrappers. Frame `clip content` uses a native +/// ``. +/// +/// 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> { + 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> { + 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("\n"); + out.push_str(&format!( + "" + )); + + if !defs.is_empty() { + out.push_str(""); + out.push_str(&defs); + out.push_str(""); + } + + out.push_str(&body); + out.push_str(""); + + 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; diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap new file mode 100644 index 0000000000..21d0fade52 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_clipped_frame_with_overflowing_child.snap @@ -0,0 +1,11 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap new file mode 100644 index 0000000000..26e6cf020a --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_group_with_two_rects_and_group_opacity.snap @@ -0,0 +1,9 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap new file mode 100644 index 0000000000..f6ae48fb7c --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_a_solid_rect.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: "render(&pool, id)" +--- + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap new file mode 100644 index 0000000000..a43537606e --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_an_unclipped_frame_with_overflowing_child.snap @@ -0,0 +1,10 @@ +--- +source: src/render/svg/tests.rs +assertion_line: 175 +expression: svg +--- + + + + + diff --git a/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap new file mode 100644 index 0000000000..5f173b6652 --- /dev/null +++ b/render-wasm/src/render/svg/snapshots/render_wasm__render__svg__tests__exports_leaf_opacity_and_blend_mode_as_group_wrappers.snap @@ -0,0 +1,8 @@ +--- +source: src/render/svg/tests.rs +expression: svg +--- + + + + diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs new file mode 100644 index 0000000000..388ef2f09b --- /dev/null +++ b/render-wasm/src/render/svg/tests.rs @@ -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("(renderer: &mut R, shape: &Shape) -> Result<()> { +pub(super) fn render_leaf_content( + 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 { // --------------------------------------------------------------------------- /// 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() { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 849d35131b..7d2fb750e4 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -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> { + 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(