mirror of
https://github.com/penpot/penpot.git
synced 2026-08-06 21:08:34 +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
65111195a9
commit
b78db68b5d
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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
70
render-wasm/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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
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]`
|
||||
/// (LE), same layout as `render_shape_pixels`. `format` selects the encoder:
|
||||
/// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`).
|
||||
|
||||
@ -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;
|
||||
|
||||
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);
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user