mirror of
https://github.com/penpot/penpot.git
synced 2026-08-30 16:48:59 +00:00
✨ 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:
parent
980ccf15fa
commit
b62512e459
@ -179,7 +179,7 @@
|
|||||||
(dom/trigger-download-uri filename mtype resource-uri)))))
|
(dom/trigger-download-uri filename mtype resource-uri)))))
|
||||||
|
|
||||||
;; TODO: Remove once we support WASM SVG export
|
;; 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?
|
(defn- wasm-export-enabled?
|
||||||
"WASM export is available when the `wasm-export/v1` feature is active AND
|
"WASM export is available when the `wasm-export/v1` feature is active AND
|
||||||
@ -202,6 +202,7 @@
|
|||||||
(effect [_ _ _]
|
(effect [_ _ _]
|
||||||
(case (:type export)
|
(case (:type export)
|
||||||
:pdf (wasm.exports/export-pdf export)
|
:pdf (wasm.exports/export-pdf export)
|
||||||
|
:svg (wasm.exports/export-svg export)
|
||||||
(wasm.exports/export-image export)))))
|
(wasm.exports/export-image export)))))
|
||||||
|
|
||||||
(defn request-simple-export
|
(defn request-simple-export
|
||||||
|
|||||||
@ -44,3 +44,17 @@
|
|||||||
(js/queueMicrotask #(wapi/revoke-uri url))
|
(js/queueMicrotask #(wapi/revoke-uri url))
|
||||||
nil))
|
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))
|
||||||
|
|
||||||
|
|||||||
@ -2870,6 +2870,24 @@
|
|||||||
(mem/free)
|
(mem/free)
|
||||||
result)))
|
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
|
(defn init-wasm-module
|
||||||
[module]
|
[module]
|
||||||
(let [default-fn (unchecked-get module "default")
|
(let [default-fn (unchecked-get module "default")
|
||||||
|
|||||||
70
render-wasm/Cargo.lock
generated
70
render-wasm/Cargo.lock
generated
@ -99,6 +99,17 @@ dependencies = [
|
|||||||
"libloading",
|
"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]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@ -114,6 +125,12 @@ version = "1.13.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encode_unicode"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@ -130,6 +147,12 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "filetime"
|
name = "filetime"
|
||||||
version = "0.2.25"
|
version = "0.2.25"
|
||||||
@ -222,6 +245,18 @@ dependencies = [
|
|||||||
"hashbrown",
|
"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]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@ -426,6 +461,7 @@ dependencies = [
|
|||||||
"gl",
|
"gl",
|
||||||
"glam",
|
"glam",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
|
"insta",
|
||||||
"macros",
|
"macros",
|
||||||
"skia-safe",
|
"skia-safe",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
@ -519,6 +555,12 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "similar"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "skia-bindings"
|
name = "skia-bindings"
|
||||||
version = "0.93.1"
|
version = "0.93.1"
|
||||||
@ -582,6 +624,19 @@ dependencies = [
|
|||||||
"xattr",
|
"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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
@ -721,6 +776,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
@ -739,6 +800,15 @@ dependencies = [
|
|||||||
"windows-targets",
|
"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]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|||||||
@ -37,6 +37,9 @@ skia-safe = { version = "0.93.1", default-features = false, features = [
|
|||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
uuid = { version = "1.11.0", features = ["v4", "js"] }
|
uuid = { version = "1.11.0", features = ["v4", "js"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
insta = "1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "fat"
|
lto = "fat"
|
||||||
|
|||||||
145
render-wasm/preview-snapshots
Executable file
145
render-wasm/preview-snapshots
Executable 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/&/\&/g; s/</\</g; s/>/\>/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
|
||||||
@ -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]`
|
/// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]`
|
||||||
/// (LE), same layout as `render_shape_pixels`. `format` selects the encoder:
|
/// (LE), same layout as `render_shape_pixels`. `format` selects the encoder:
|
||||||
/// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`).
|
/// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`).
|
||||||
|
|||||||
@ -13,6 +13,7 @@ mod shadows;
|
|||||||
pub mod shape_renderer;
|
pub mod shape_renderer;
|
||||||
mod strokes;
|
mod strokes;
|
||||||
mod surfaces;
|
mod surfaces;
|
||||||
|
pub mod svg;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
pub mod text_editor;
|
pub mod text_editor;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|||||||
238
render-wasm/src/render/svg/document.rs
Normal file
238
render-wasm/src/render/svg/document.rs
Normal 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
|
||||||
|
}
|
||||||
71
render-wasm/src/render/svg/fixtures.rs
Normal file
71
render-wasm/src/render/svg/fixtures.rs
Normal 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")
|
||||||
|
}
|
||||||
66
render-wasm/src/render/svg/frames.rs
Normal file
66
render-wasm/src/render/svg/frames.rs
Normal 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(())
|
||||||
|
}
|
||||||
35
render-wasm/src/render/svg/groups.rs
Normal file
35
render-wasm/src/render/svg/groups.rs
Normal 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(())
|
||||||
|
}
|
||||||
167
render-wasm/src/render/svg/mod.rs
Normal file
167
render-wasm/src/render/svg/mod.rs
Normal 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;
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
176
render-wasm/src/render/svg/tests.rs
Normal file
176
render-wasm/src/render/svg/tests.rs
Normal 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);
|
||||||
|
}
|
||||||
@ -970,7 +970,10 @@ fn render_leaf(
|
|||||||
/// Single source of truth for leaf content draw order/gating (fills, inner
|
/// Single source of truth for leaf content draw order/gating (fills, inner
|
||||||
/// shadows, strokes), generic over [`ShapeRenderer`]. Drop shadows and layer
|
/// shadows, strokes), generic over [`ShapeRenderer`]. Drop shadows and layer
|
||||||
/// blur are excluded — they wrap the content and are sequenced per backend.
|
/// 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 {
|
match &shape.shape_type {
|
||||||
Type::Text(_) => renderer.draw_text(shape)?,
|
Type::Text(_) => renderer.draw_text(shape)?,
|
||||||
Type::SVGRaw(_) => renderer.draw_svg(shape)?,
|
Type::SVGRaw(_) => renderer.draw_svg(shape)?,
|
||||||
@ -1212,7 +1215,7 @@ fn transformed_skia_path(shape: &Shape) -> Option<skia::Path> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Draws the shape's geometry (rect/rrect/oval/path) with the given paint.
|
/// 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 {
|
match &shape.shape_type {
|
||||||
Type::Rect(_) | Type::Frame(_) => {
|
Type::Rect(_) | Type::Frame(_) => {
|
||||||
if let Some(corners) = shape.shape_type.corners() {
|
if let Some(corners) = shape.shape_type.corners() {
|
||||||
|
|||||||
@ -105,6 +105,10 @@ impl State {
|
|||||||
crate::render::pdf::render_to_pdf(get_resources(), id, &self.shapes, scale)
|
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
|
/// GPU-free counterpart of [`State::render_shape_pixels`]: encodes to
|
||||||
/// `format` on a CPU raster surface, no GPU/WebGL.
|
/// `format` on a CPU raster surface, no GPU/WebGL.
|
||||||
pub fn render_shape_raster(
|
pub fn render_shape_raster(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user