✨ Use WASM for SVG markup in code inspect and copy (#12567) (#12608)

When render-wasm is active, generate SVG for the Code inspector and
Copy as SVG from the WASM export path instead of classic SVG codegen.
Add multi-root render_shapes_svg so multi-select stays one document.

Closes #12567
This commit is contained in:
Alejandro Alonso 2026-10-27 12:05:30 +01:00 committed by GitHub
parent 112e6dedd7
commit deb5771248
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 357 additions and 8 deletions

View File

@ -56,6 +56,7 @@
[app.render-wasm.text-paste :as text-paste]
[app.util.clipboard :as clipboard]
[app.util.code-gen.markup-svg :as svg]
[app.util.code-gen.markup-svg-wasm :as svg.wasm]
[app.util.code-gen.style-css :as css]
[app.util.globals :as ug]
[app.util.http :as http]
@ -392,7 +393,10 @@
(gsh/translate-to-frame % (get objects parent-frame-id)))
shapes (mapv maybe-translate selected)
svg-formatted (svg/generate-formatted-markup objects shapes)]
;; With render-wasm active, never call classic SVG codegen.
svg-formatted (if (features/active-feature? state "render-wasm/v1")
(svg.wasm/get-formatted-svg-markup (mapv :id shapes))
(svg/generate-formatted-markup objects shapes))]
(-> (clipboard/to-clipboard-multi
{"image/svg+xml" svg-formatted
"text/plain" svg-formatted})

View File

@ -14,6 +14,7 @@
[app.common.types.shape-tree :as ctst]
[app.config :as cfg]
[app.main.data.event :as ev]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.refs :as refs]
[app.main.store :as st]
@ -28,6 +29,7 @@
[app.util.clipboard :as clipboard]
[app.util.code-beautify :as cb]
[app.util.code-gen :as cg]
[app.util.code-gen.markup-svg-wasm :as svg.wasm]
[app.util.dom :as dom]
[app.util.http :as http]
[app.util.i18n :refer [tr]]
@ -116,6 +118,9 @@
collapsed-css? (contains? @collapsed* :css)
collapsed-markup? (contains? @collapsed* :markup)
;; With render-wasm active, never use classic SVG codegen for SVG markup.
wasm-svg-markup? (features/use-feature "render-wasm/v1")
objects (use-objects from)
shapes
@ -151,9 +156,11 @@
markup-code
(mf/use-memo
(mf/deps markup-type shapes images-data)
(mf/deps markup-type shapes images-data wasm-svg-markup?)
(fn []
(cg/generate-formatted-markup-code objects markup-type shapes)))
(if (and (= markup-type "svg") wasm-svg-markup?)
(svg.wasm/get-formatted-svg-markup (mapv :id shapes))
(cg/generate-formatted-markup-code objects markup-type shapes))))
on-markup-copied
(mf/use-fn

View File

@ -3073,6 +3073,31 @@
(mem/free)
result)))
(defn render-shapes-svg
"Renders the given shape ids into one SVG document (joined viewBox).
IDs are written to the transfer buffer (same layout as `render-shapes-pdf`)."
[shape-ids scale]
(when (and (initialized?) (seq shape-ids))
(let [ids (vec shape-ids)
size (mem/get-alloc-size ids UUID-U8-SIZE)
heap (mem/get-heap-u32)
offset (mem/alloc->offset-32 size)
_ (reduce (fn [offset id]
(mem.h32/write-uuid offset heap id))
offset
ids)
result-offset
(h/call wasm/internal-module "_render_shapes_svg" scale)
heapu8 (mem/get-heap-u8)
heapu32 (mem/get-heap-u32)
length (aget heapu32 (mem/->offset-32 result-offset))
result (dr/read-image-bytes heapu8 (+ result-offset 4) length)]
(mem/free)
result)))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")

View File

@ -0,0 +1,56 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.util.code-gen.markup-svg-wasm
"SVG markup via the render-wasm export path (inspect / copy-as-svg).
Callers must gate on `render-wasm/v1` and must not fall back to classic
SVG codegen while that feature is on."
(:require
[app.render-wasm.api :as wasm.api]
[app.util.code-beautify :as cb]
[cuerdas.core :as str]))
(defn- decode-utf8
[bytes]
(when bytes
(.decode (js/TextDecoder.) bytes)))
(defn get-svg-markup
"UTF-8 SVG string for the given shape ids via the main-thread WASM module.
Empty string when WASM is not ready, ids are empty, or export throws."
[shape-ids]
(let [ids (vec shape-ids)]
(cond
(empty? ids)
""
(not (wasm.api/initialized?))
""
:else
(try
(or (decode-utf8
(if (= 1 (count ids))
(wasm.api/render-shape-svg (first ids) 1)
(wasm.api/render-shapes-svg ids 1)))
"")
;; Missing shape / export failure is a WASM CriticalError (:panic).
;; Letting it reach the global handler would show Internal Error and
;; block the workspace; the user can still work without this markup, so
;; log and return empty instead (same pattern as other non-fatal WASM UI paths).
(catch :default cause
(js/console.error "WASM SVG markup failed:" cause)
"")))))
(defn get-formatted-svg-markup
"Beautified SVG markup for inspect / copy-as-svg.
Skips beautify when markup is empty so inspect/copy stay safe without
js-beautify (and avoid a no-op format call)."
[shape-ids]
(let [markup (get-svg-markup shape-ids)]
(if (str/empty? markup)
""
(cb/format-code markup "svg"))))

View File

@ -151,6 +151,7 @@
[frontend-tests.util-text-editor-test]
[frontend-tests.util-webapi-test]
[frontend-tests.util-zip-test]
[frontend-tests.util.code-gen-markup-svg-wasm-test]
[frontend-tests.util.dom.dnd-test]
[frontend-tests.worker-snap-test]
[frontend-tests.worker.exports-test]
@ -315,6 +316,7 @@
'frontend-tests.util-text-clipboard-test
'frontend-tests.util-text-editor-test
'frontend-tests.util-webapi-test
'frontend-tests.util.code-gen-markup-svg-wasm-test
'frontend-tests.util.dom.dnd-test
'frontend-tests.util-zip-test
'frontend-tests.worker-snap-test])

View File

@ -0,0 +1,67 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.util.code-gen-markup-svg-wasm-test
"Unit tests for WASM SVG markup helpers used by Code inspect and Copy as SVG."
(:require
[app.common.uuid :as uuid]
[app.render-wasm.api :as wasm.api]
[app.util.code-gen.markup-svg-wasm :as svg.wasm]
[cljs.test :as t :include-macros true]))
(defn- utf8-bytes
[s]
(.encode (js/TextEncoder.) s))
(t/deftest get-svg-markup-empty-ids
(t/is (= "" (svg.wasm/get-svg-markup []))))
(t/deftest get-svg-markup-not-initialized
(with-redefs [wasm.api/initialized? (constantly false)]
(t/is (= "" (svg.wasm/get-svg-markup [(uuid/next)])))))
(t/deftest get-svg-markup-single-shape
(let [id (uuid/next)
called (atom nil)]
(with-redefs [wasm.api/initialized? (constantly true)
wasm.api/render-shape-svg
(fn [shape-id scale]
(reset! called [shape-id scale])
(utf8-bytes "<svg single=\"1\"/>"))]
(t/is (= "<svg single=\"1\"/>" (svg.wasm/get-svg-markup [id])))
(t/is (= [id 1] @called)))))
(t/deftest get-svg-markup-multi-shapes-one-document
(let [ids [(uuid/next) (uuid/next)]
called (atom nil)]
(with-redefs [wasm.api/initialized? (constantly true)
wasm.api/render-shapes-svg
(fn [shape-ids scale]
(reset! called [shape-ids scale])
(utf8-bytes "<svg multi=\"1\"/>"))]
(t/is (= "<svg multi=\"1\"/>" (svg.wasm/get-svg-markup ids)))
(t/is (= [ids 1] @called))
(t/is (= 1 (count (re-seq #"<svg\b" (svg.wasm/get-svg-markup ids))))))))
(defn- wasm-panic
[]
(throw (ex-info "WASM Error (panic)"
{:type :wasm-error
:code :panic
:hint "WASM Error (panic)"
:fn "_render_shape_svg"})))
(t/deftest get-svg-markup-swallows-wasm-errors
(with-redefs [wasm.api/initialized? (constantly true)
wasm.api/render-shape-svg (fn [_ _] (wasm-panic))]
(t/is (= "" (svg.wasm/get-svg-markup [(uuid/next)]))))
(with-redefs [wasm.api/initialized? (constantly true)
wasm.api/render-shapes-svg (fn [_ _] (wasm-panic))]
(t/is (= "" (svg.wasm/get-svg-markup [(uuid/next) (uuid/next)])))))
(t/deftest get-formatted-svg-markup-safe-on-empty
(with-redefs [wasm.api/initialized? (constantly false)]
(t/is (= "" (svg.wasm/get-formatted-svg-markup [(uuid/next)])))))

View File

@ -938,6 +938,28 @@ pub extern "C" fn render_shape_svg(a: u32, b: u32, c: u32, d: u32, scale: f32) -
})
}
/// Multi-root SVG: shape UUIDs are read from the transfer buffer (16 bytes
/// each, same layout as `render_shapes_pdf`). Returns `[len u32 LE][svg bytes]`.
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_shapes_svg(scale: f32) -> Result<*mut u8> {
let bytes = mem::bytes();
let ids: Vec<Uuid> = bytes
.chunks(size_of::<<Uuid as SerializableResult>::BytesType>())
.map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string())))
.collect::<Result<Vec<Uuid>>>()?;
with_state!(state, {
let data = state.render_shapes_svg(&ids, scale)?;
let len = data.len() as u32;
let mut buf = Vec::with_capacity(4 + data.len());
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&data);
Ok(mem::write_bytes(buf))
})
}
/// Raster image via CPU (no GPU/WebGL). Returns `[len][width][height][bytes]`
/// (LE), same layout as `render_shape_pixels`. `format` selects the encoder:
/// 0 = PNG, 1 = JPEG, 2 = WEBP (see `RasterFormat`).

View File

@ -15,7 +15,7 @@ use crate::state::ShapesPool;
use crate::utils::uuid_from_u32_quartet;
use crate::uuid::Uuid;
use super::render_tree_to_svg;
use super::{render_tree_to_svg, render_trees_to_svg};
/// Font URL referenced in exported SVG `@font-face` rules.
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf";
@ -607,6 +607,18 @@ pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
render_with(pool, root, |_resources| {})
}
/// Multi-root SVG export for tests (one document, joined viewBox).
pub(super) fn render_multi(pool: &ShapesPool, roots: &[Uuid]) -> String {
let _serial = svg_export_test_lock()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let mut resources = RenderResources::try_new_headless().expect("headless resources");
register_test_font_urls(&mut resources.fonts);
let _guard = TestRenderResourcesGuard::install(&mut resources);
let bytes = render_trees_to_svg(&mut resources, roots, pool, 1.0).expect("svg multi export");
String::from_utf8(bytes).expect("utf8 svg")
}
/// SVG export installs a process-wide resources pointer; serialize tests that
/// call this so parallel rustc threads do not race / SIGSEGV.
fn svg_export_test_lock() -> &'static Mutex<()> {

View File

@ -104,6 +104,88 @@ pub(crate) fn render_tree_to_svg(
collect_font_aliases(tree, id, &mut aliases);
let font_css = shared.fonts.font_face_css_for_aliases(&aliases);
Ok(emit_svg_document(page_w, page_h, &font_css, &defs, &body))
}
/// Multi-root SVG export: one document whose viewBox covers every visible root,
/// with relative placement preserved (inspect / copy-as-svg multi-select).
pub fn render_to_svg_multi(
shared: &mut RenderResources,
ids: &[Uuid],
tree: ShapesPoolRef,
scale: f32,
) -> Result<Vec<u8>> {
render_trees_to_svg(shared, ids, tree, scale)
}
/// Core multi-root SVG export for headless tests and the WASM bridge.
pub(crate) fn render_trees_to_svg(
shared: &mut RenderResources,
ids: &[Uuid],
tree: ShapesPoolRef,
scale: f32,
) -> Result<Vec<u8>> {
if ids.is_empty() {
return Ok(emit_svg_document(0.0, 0.0, "", "", ""));
}
let mut joined: Option<skia::Rect> = None;
let mut roots: Vec<Uuid> = Vec::with_capacity(ids.len());
for id in ids {
let Some(shape) = tree.get(id) else {
continue;
};
if shape.hidden {
continue;
}
let bounds = svg_page_bounds(shape, tree, scale);
match &mut joined {
None => joined = Some(bounds),
Some(acc) => {
acc.join(bounds);
}
}
roots.push(*id);
}
// Missing or hidden-only roots: empty document (same as empty `ids`), not
// CriticalError — inspect/copy must not crash the host when nothing is drawable.
let Some(bounds) = joined else {
return Ok(emit_svg_document(0.0, 0.0, "", "", ""));
};
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 tx = -bounds.left();
let ty = -bounds.top();
let mut builder = SvgLayerCanvas::new(scale, rect, tx, ty);
for id in &roots {
if let Some(shape) = tree.get(id) {
builder.nested_fills.push(shape.inherited_fills(tree));
}
render_tree(&mut builder, shared, id, tree, scale)?;
builder.nested_fills.pop();
}
builder.flush();
let mut aliases = HashSet::new();
for id in &roots {
collect_font_aliases(tree, id, &mut aliases);
}
let font_css = shared.fonts.font_face_css_for_aliases(&aliases);
Ok(emit_svg_document(
page_w,
page_h,
&font_css,
&builder.defs,
&builder.out,
))
}
fn emit_svg_document(page_w: f32, page_h: f32, font_css: &str, defs: &str, body: &str) -> Vec<u8> {
let mut out = String::with_capacity(body.len() + defs.len() + font_css.len() + 256);
out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n");
out.push_str(&format!(
@ -118,14 +200,13 @@ pub(crate) fn render_tree_to_svg(
"<style type=\"text/css\"><![CDATA[{font_css}]]></style>"
));
}
out.push_str(&defs);
out.push_str(defs);
out.push_str("</defs>");
}
out.push_str(&body);
out.push_str(body);
out.push_str("</svg>");
Ok(out.into_bytes())
out.into_bytes()
}
mod background_blur;

View File

@ -58,6 +58,75 @@ fn exports_a_solid_rect() {
insta::assert_snapshot!(render(&pool, id));
}
#[test]
fn exports_multiple_roots_in_one_svg_with_relative_placement() {
let mut pool = ShapesPool::new();
let left = uid(1);
let right = uid(2);
add_solid_rect(
&mut pool,
left,
Uuid::nil(),
(0.0, 0.0, 50.0, 50.0),
skia::Color::from_rgb(255, 0, 0),
);
add_solid_rect(
&mut pool,
right,
Uuid::nil(),
(100.0, 0.0, 150.0, 50.0),
skia::Color::from_rgb(0, 0, 255),
);
let svg = render_multi(&pool, &[left, right]);
assert_eq!(
svg.matches("<svg").count(),
1,
"multi-root export must emit one <svg>: {svg}"
);
assert_eq!(
svg.matches("</svg>").count(),
1,
"multi-root export must emit one </svg>: {svg}"
);
// Joined page is 150×50; both fills present and the right rect keeps x=100.
assert!(
svg.contains("viewBox=\"0 0 150 50\""),
"joined viewBox should span both rects: {svg}"
);
assert!(svg.contains("fill=\"red\""), "missing left red fill: {svg}");
assert!(
svg.contains("fill=\"blue\"") && svg.contains("x=\"100\""),
"missing right blue fill at x=100: {svg}"
);
}
#[test]
fn exports_multi_root_all_hidden_or_missing_as_empty_svg() {
let mut pool = ShapesPool::new();
let hidden = uid(1);
add_solid_rect(
&mut pool,
hidden,
Uuid::nil(),
(0.0, 0.0, 50.0, 50.0),
skia::Color::from_rgb(255, 0, 0),
);
pool.get_mut(&hidden).unwrap().set_hidden(true);
let missing = uid(99);
let svg = render_multi(&pool, &[hidden, missing]);
assert_eq!(svg.matches("<svg").count(), 1, "empty multi export: {svg}");
assert!(
svg.contains("viewBox=\"0 0 0 0\"") || svg.contains("width=\"0\""),
"no drawable roots should yield an empty page: {svg}"
);
assert!(
!svg.contains("fill=\"red\""),
"hidden root must not paint: {svg}"
);
}
#[test]
fn exports_leaf_opacity_and_blend_mode_as_group_wrappers() {
let mut pool = ShapesPool::new();

View File

@ -130,6 +130,10 @@ impl State {
crate::render::svg::render_to_svg(get_resources(), id, &self.shapes, scale)
}
pub fn render_shapes_svg(&mut self, ids: &[Uuid], scale: f32) -> Result<Vec<u8>> {
crate::render::svg::render_to_svg_multi(get_resources(), ids, &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(