mirror of
https://github.com/penpot/penpot.git
synced 2026-10-28 21:40:34 +00:00
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:
parent
112e6dedd7
commit
deb5771248
@ -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})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
56
frontend/src/app/util/code_gen/markup_svg_wasm.cljs
Normal file
56
frontend/src/app/util/code_gen/markup_svg_wasm.cljs
Normal 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"))))
|
||||
@ -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])
|
||||
|
||||
@ -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)])))))
|
||||
@ -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`).
|
||||
|
||||
@ -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<()> {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user