diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 37f7ef80b4..877e535f75 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -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}) diff --git a/frontend/src/app/main/ui/inspect/code.cljs b/frontend/src/app/main/ui/inspect/code.cljs index b96759932a..d7a170a67e 100644 --- a/frontend/src/app/main/ui/inspect/code.cljs +++ b/frontend/src/app/main/ui/inspect/code.cljs @@ -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 diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f5af3275dd..8a4ad7d427 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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") diff --git a/frontend/src/app/util/code_gen/markup_svg_wasm.cljs b/frontend/src/app/util/code_gen/markup_svg_wasm.cljs new file mode 100644 index 0000000000..4342ced0b6 --- /dev/null +++ b/frontend/src/app/util/code_gen/markup_svg_wasm.cljs @@ -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")))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index f2585704e8..5e18876e88 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -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]) diff --git a/frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs b/frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs new file mode 100644 index 0000000000..62df62eb59 --- /dev/null +++ b/frontend/test/frontend_tests/util/code_gen_markup_svg_wasm_test.cljs @@ -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 ""))] + (t/is (= "" (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 ""))] + (t/is (= "" (svg.wasm/get-svg-markup ids))) + (t/is (= [ids 1] @called)) + (t/is (= 1 (count (re-seq #" Result<*mut u8> { + let bytes = mem::bytes(); + let ids: Vec = bytes + .chunks(size_of::<::BytesType>()) + .map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string()))) + .collect::>>()?; + + 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`). diff --git a/render-wasm/src/render/svg/fixtures.rs b/render-wasm/src/render/svg/fixtures.rs index a33d5d258e..a112752b6b 100644 --- a/render-wasm/src/render/svg/fixtures.rs +++ b/render-wasm/src/render/svg/fixtures.rs @@ -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<()> { diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index ae4fdd3c08..997a68e71c 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -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> { + 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> { + if ids.is_empty() { + return Ok(emit_svg_document(0.0, 0.0, "", "", "")); + } + + let mut joined: Option = None; + let mut roots: Vec = 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 { let mut out = String::with_capacity(body.len() + defs.len() + font_css.len() + 256); out.push_str("\n"); out.push_str(&format!( @@ -118,14 +200,13 @@ pub(crate) fn render_tree_to_svg( "" )); } - out.push_str(&defs); + out.push_str(defs); out.push_str(""); } - out.push_str(&body); + out.push_str(body); out.push_str(""); - - Ok(out.into_bytes()) + out.into_bytes() } mod background_blur; diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 87eeb29fb4..490d5ef27a 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -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}" + ); + assert_eq!( + svg.matches("").count(), + 1, + "multi-root export must emit one : {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(" Result> { + 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(