diff --git a/common/src/app/common/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs index 868c0321f5..07ce3697ab 100644 --- a/common/src/app/common/render_wasm/serialize_shape.cljs +++ b/common/src/app/common/render_wasm/serialize_shape.cljs @@ -25,16 +25,52 @@ dispatching per changed key through the same underlying `props` setters." (:require [app.common.render-wasm.api.props :as props] - [app.common.render-wasm.api.upload :as upload])) + [app.common.render-wasm.api.upload :as upload] + [app.common.render-wasm.svg-derived :as svg-derived])) + +(defn needs-shape-tail? + "True when `shape` needs the per-shape svg-attrs/path tail after a + structural upload: svg-attrs present, or `:path`/`:bool` with content." + [shape] + (let [type (:type shape)] + (or (some? (:svg-attrs shape)) + (and (contains? #{:path :bool} type) + (some? (:content shape)))))) + +(defn- write-shape-tail! + "Per-shape svg-attrs/path tail after a structural upload." + [shape] + (when (some? (:svg-attrs shape)) + (props/set-shape-svg-attrs (:svg-attrs shape))) + + (let [type (:type shape)] + (when (and (contains? #{:path :bool} type) (some? (:content shape))) + (props/set-shape-path-content (:content shape))))) (defn serialize-shape! "Applies every host-independent WASM property of `shape`." [shape] - (let [type (get shape :type)] + (let [shape (svg-derived/apply-svg-derived shape)] (upload/set-shape-upload! shape {:include-layout? false}) - (when (some? (get shape :svg-attrs)) - (props/set-shape-svg-attrs (get shape :svg-attrs))) + (when (needs-shape-tail? shape) + (write-shape-tail! shape)) - (when (and (contains? #{:path :bool} type) (some? (get shape :content))) - (props/set-shape-path-content (get shape :content))))) + shape)) + +(defn serialize-shapes-batch! + "Structural batch upload plus per-shape svg-attrs/path tail. + + Derives via svg-derived, uploads one `_set_shapes_batch` with `opts`, + then selects each shape needing svg-attrs/path content and applies it. + Host text/grid/image sequencing stays in callers. Returns the prepared + vector for the downstream host-attrs loop." + [shapes opts select-fn] + (let [prepared (mapv svg-derived/apply-svg-derived shapes)] + (when (seq prepared) + (upload/flush-shapes-batch! prepared opts) + (doseq [shape prepared] + (when (needs-shape-tail? shape) + (select-fn (:id shape)) + (write-shape-tail! shape)))) + prepared)) diff --git a/frontend/src/app/render_wasm/svg_fills.cljs b/common/src/app/common/render_wasm/svg_derived.cljs similarity index 80% rename from frontend/src/app/render_wasm/svg_fills.cljs rename to common/src/app/common/render_wasm/svg_derived.cljs index 829612bb2b..314f91f195 100644 --- a/frontend/src/app/render_wasm/svg_fills.cljs +++ b/common/src/app/common/render_wasm/svg_derived.cljs @@ -4,7 +4,8 @@ ;; ;; Copyright (c) KALEIDOS SUBSIDIARY SL -(ns app.render-wasm.svg-fills +(ns app.common.render-wasm.svg-derived + "SVG-derived WASM attrs (fills, blur, shadows), shared by frontend and export" (:require [app.common.data :as d] [app.common.data.macros :as dm] @@ -12,6 +13,7 @@ [app.common.geom.rect :as grc] [app.common.svg :as csvg] [app.common.types.color :as clr] + [app.common.uuid :as uuid] [clojure.string :as str])) (def ^:private url-fill-pattern @@ -380,3 +382,86 @@ [{:fill-color "#000000" :fill-opacity 1}] :else []))) +(def ^:private drop-shadow-tags + #{:feOffset :feGaussianBlur :feColorMatrix}) + +(defn- find-filter-element + "Finds a filter element by tag in filter content." + [filter-content tag] + (some #(when (= tag (:tag %)) %) filter-content)) + +(defn- find-filter-def + [shape] + (let [filter-attr (or (dm/get-in shape [:svg-attrs :filter]) + (dm/get-in shape [:svg-attrs :style :filter])) + svg-defs (dm/get-prop shape :svg-defs)] + (when (and filter-attr svg-defs) + (let [filter-ids (csvg/extract-ids filter-attr)] + (some #(get svg-defs %) filter-ids))))) + +(defn- build-blur + [gaussian-blur] + (when gaussian-blur + {:id (uuid/next) + :type :layer-blur + ;; For layer blur the value matches stdDeviation directly + :value (-> (dm/get-in gaussian-blur [:attrs :stdDeviation]) + (d/parse-double 0)) + :hidden false})) + +(defn- build-drop-shadow + [filter-content drop-shadow-elements] + (let [offset-elem (find-filter-element filter-content :feOffset)] + (when (and offset-elem (seq drop-shadow-elements)) + (let [blur-elem (find-filter-element drop-shadow-elements :feGaussianBlur) + dx (-> (dm/get-in offset-elem [:attrs :dx]) + (d/parse-double 0)) + dy (-> (dm/get-in offset-elem [:attrs :dy]) + (d/parse-double 0)) + blur-value (if blur-elem + (-> (dm/get-in blur-elem [:attrs :stdDeviation]) + (d/parse-double 0) + (* 2)) + 0)] + [{:id (uuid/next) + :style :drop-shadow + :offset-x dx + :offset-y dy + :blur blur-value + :spread 0 + :hidden false + ;; TODO: parse feColorMatrix to extract color/opacity + :color {:color "#000000" :opacity 1}}])))) + +(defn apply-svg-filters + "Derives native blur/shadow from SVG filter definitions when the shape does + not already have them. The SVG attributes are left untouched so SVG fallback + rendering keeps working the same way as gradient fills." + [shape] + (let [existing-blur (:blur shape) + existing-shadow (:shadow shape)] + (if-let [filter-def (find-filter-def shape)] + (let [content (:content filter-def) + gaussian-blur (find-filter-element content :feGaussianBlur) + drop-shadow-elements (filter #(contains? drop-shadow-tags (:tag %)) content) + blur (or existing-blur (build-blur gaussian-blur)) + shadow (if (seq existing-shadow) + existing-shadow + (build-drop-shadow content drop-shadow-elements))] + (cond-> shape + blur (assoc :blur blur) + (seq shadow) (assoc :shadow shadow))) + shape))) + +(defn apply-svg-derived + "Applies SVG-derived effects (fills, blur, shadows) uniformly. + - Keeps user fills if present; otherwise derives from SVG. + - Converts SVG filters into native blur/shadow when needed. + - Always returns shape with :fills (possibly []) and blur/shadow keys." + [shape] + (let [shape' (apply-svg-filters shape) + fills (or (resolve-shape-fills shape') [])] + (assoc shape' + :fills fills + :blur (:blur shape') + :shadow (:shadow shape')))) diff --git a/exporter/scripts/test b/exporter/scripts/test index 169cdc8a9e..040341ed26 100755 --- a/exporter/scripts/test +++ b/exporter/scripts/test @@ -2,4 +2,7 @@ set -ex pnpm install; +pushd ../render-wasm; +./build export; +popd pnpm run test; diff --git a/exporter/src/app/wasm/serialize.cljs b/exporter/src/app/wasm/serialize.cljs index f3edb39a6a..6f316187c7 100644 --- a/exporter/src/app/wasm/serialize.cljs +++ b/exporter/src/app/wasm/serialize.cljs @@ -28,13 +28,13 @@ host-specific parts are handled here: fills/strokes (image bytes are provisioned separately) and text content (fonts provisioned separately)." [shape] - (let [type (get shape :type)] - (serialize-shape/serialize-shape! shape) - (props/write-shape-fills! (get shape :fills)) + (let [prepared (serialize-shape/serialize-shape! shape) + type (:type prepared)] + (props/write-shape-fills! (:fills prepared)) (when-not (= type :group) - (props/write-shape-strokes! (get shape :strokes))) + (props/write-shape-strokes! (:strokes prepared))) (when (= type :text) - (text/set-shape-text! (get shape :content))))) + (text/set-shape-text! (:content prepared))))) (defn serialize-scene! "Loads every shape of an `objects` map into the WASM design state. Resets the diff --git a/exporter/test/exporter_tests/renderer_svg_test.cljs b/exporter/test/exporter_tests/renderer_svg_test.cljs index 3c841a2d77..7d6bd68899 100644 --- a/exporter/test/exporter_tests/renderer_svg_test.cljs +++ b/exporter/test/exporter_tests/renderer_svg_test.cljs @@ -7,7 +7,11 @@ (ns exporter-tests.renderer-svg-test (:require [app.renderer.svg-gradient :as svg-gradient] - [cljs.test :refer [deftest is testing]])) + [app.wasm :as wasm-io] + [app.wasm.serialize :as serialize] + [cljs.test :refer [deftest is testing async]] + [clojure.string :as str] + [promesa.core :as p])) (def gradient-stops [{"color" "#000000" "offset" 0 "opacity" 1} @@ -23,3 +27,46 @@ "stops" gradient-stops}} result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])] (is (= element-name (get result "name"))))))) + + +(def ^:private svg-red-id (random-uuid)) +(def ^:private blue-id (random-uuid)) + +(defn- scene [] + {svg-red-id {:id svg-red-id + :type :rect + :name "svg-red" + :x 10 :y 10 :width 100 :height 100 + :rotation 0 + :selrect {:x 10 :y 10 :x1 10 :y1 10 :x2 110 :y2 110 + :width 100 :height 100} + :strokes [] + :svg-attrs {:fill "#ff0000"}} + blue-id {:id blue-id + :type :rect + :name "user-blue" + :x 130 :y 10 :width 100 :height 100 + :rotation 0 + :selrect {:x 130 :y 10 :x1 130 :y1 10 :x2 230 :y2 110 + :width 100 :height 100} + :strokes [] + :fills [{:fill-color "#0000ff" :fill-opacity 1}]}}) + +(defn- render-svg-string + [shape-id] + (.toString (js/Buffer.from (wasm-io/render-shape-svg shape-id 1)))) + +(deftest exporter-honours-svg-attr-fills + (async done + (->> (wasm-io/init!) + (p/mcat + (fn [_] + (serialize/serialize-scene! (scene)) + (let [red (render-svg-string svg-red-id) + blue (render-svg-string blue-id)] + (is (str/includes? blue "blue") + "control: user fills render, so the harness works") + (is (str/includes? red "fill=\"red\"") + "svg-attr fill survives headless serialization") + (p/resolved nil)))) + (p/mcat (fn [_] (done)))))) diff --git a/frontend/src/app/main/data/workspace/path/edition.cljs b/frontend/src/app/main/data/workspace/path/edition.cljs index 5e0f9f8c24..dcd40d6962 100644 --- a/frontend/src/app/main/data/workspace/path/edition.cljs +++ b/frontend/src/app/main/data/workspace/path/edition.cljs @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] + [app.common.render-wasm.svg-derived :as svg-derived] [app.common.types.path :as path] [app.common.types.path.helpers :as path.helpers] [app.main.data.helpers :as dsh] @@ -22,7 +23,6 @@ [app.main.data.workspace.path.tools :as tools] [app.main.data.workspace.path.undo :as undo] [app.main.streams :as ms] - [app.render-wasm.svg-fills :as svg-fills] [app.util.mouse :as mse] [beicon.v2.core :as rx] [beicon.v2.operators :as rxo] @@ -885,7 +885,7 @@ "Resolves the fills inherited by the editing copy. Frames stop group fill inheritance." [shape objects] - (let [own (svg-fills/resolve-shape-fills shape)] + (let [own (svg-derived/resolve-shape-fills shape)] (if (seq own) own (loop [parent-id (:parent-id shape) @@ -897,7 +897,7 @@ (let [parent (get objects parent-id)] (cond (nil? parent) [] - (cfh/group-shape? parent) (let [fills (svg-fills/resolve-shape-fills parent)] + (cfh/group-shape? parent) (let [fills (svg-derived/resolve-shape-fills parent)] (if (seq fills) fills (recur (:parent-id parent) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 8cfda5e150..0c5a4c3d0b 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -17,7 +17,6 @@ [app.common.logging :as log] [app.common.math :as mth] [app.common.render-wasm.api.props :as props] - [app.common.render-wasm.api.upload :as upload] [app.common.render-wasm.helpers :as h] [app.common.render-wasm.mem :as mem] [app.common.render-wasm.mem.heap32 :as mem.h32] @@ -50,7 +49,6 @@ [app.render-wasm.gesture :as wasm-gesture] [app.render-wasm.performance :as perf] [app.render-wasm.rulers-state :as rulers-state] - [app.render-wasm.svg-filters :as svg-filters] [app.render-wasm.text-editor :as text-editor] [app.util.debug :as dbg] [app.util.dom :as dom] @@ -1645,11 +1643,10 @@ {:thumbnails [] :full [] :font-face-keys #{} :pending-font-face-keys #{}} (do (perf/begin-measure "set-object") - (let [shape (svg-filters/apply-svg-derived shape)] - (serialize-shape/serialize-shape! shape) - (let [result (set-object-host-attrs shape false)] - (perf/end-measure "set-object") - result))))) + (let [prepared (serialize-shape/serialize-shape! shape) + result (set-object-host-attrs prepared false)] + (perf/end-measure "set-object") + result)))) (defn- update-text-layouts "Synchronously update text layouts for all shapes and send rect updates @@ -1814,24 +1811,11 @@ end-index (min total (+ start-index BATCH_MAX_SHAPES)) chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes)) start-index end-index)) - prepared (mapv svg-filters/apply-svg-derived chunk)] - - ;; One multi-shape structural upload (base+children+blur+shadows+flex+item+fills+strokes). - (when (seq prepared) - (upload/flush-shapes-batch! prepared {:include-layout? true - :include-fills-strokes? true})) - - ;; Path + svg-attrs still need the legacy per-shape path (variable/large). - (doseq [shape prepared] - (let [id (dm/get-prop shape :id) - type (dm/get-prop shape :type)] - (when (or (some? (get shape :svg-attrs)) - (and (contains? #{:path :bool} type) (some? (get shape :content)))) - (use-shape id) - (when (some? (get shape :svg-attrs)) - (props/set-shape-svg-attrs (get shape :svg-attrs))) - (when (and (contains? #{:path :bool} type) (some? (get shape :content))) - (props/set-shape-path-content (get shape :content)))))) + prepared (serialize-shape/serialize-shapes-batch! + chunk + {:include-layout? true + :include-fills-strokes? true} + use-shape)] (loop [xs prepared t-acc (transient thumbnails-acc) @@ -1977,53 +1961,44 @@ (defn- set-objects-sync "Synchronously process all shapes (for small shape counts)." [shapes render-callback on-shapes-ready] - (let [prepared (mapv svg-filters/apply-svg-derived shapes)] - (when (seq prepared) - (upload/flush-shapes-batch! prepared {:include-layout? true - :include-fills-strokes? true})) - (doseq [shape prepared] - (let [id (dm/get-prop shape :id) - type (dm/get-prop shape :type)] - (when (or (some? (get shape :svg-attrs)) - (and (contains? #{:path :bool} type) (some? (get shape :content)))) - (use-shape id) - (when (some? (get shape :svg-attrs)) - (props/set-shape-svg-attrs (get shape :svg-attrs))) - (when (and (contains? #{:path :bool} type) (some? (get shape :content))) - (props/set-shape-path-content (get shape :content)))))) - (let [total-shapes (count prepared) - {:keys [thumbnails full text-font-state]} - (loop [index 0 - thumbnails-acc (transient []) - full-acc (transient []) - font-state-acc empty-text-font-state] - (if (< index total-shapes) - (let [shape (nth prepared index) - {:keys [thumbnails full font-face-keys pending-font-face-keys]} - (set-object-host-attrs shape true :skip-fills-strokes? true)] - (recur (inc index) - (reduce conj! thumbnails-acc thumbnails) - (reduce conj! full-acc full) - (acc-text-font-state font-state-acc - (:id shape) - font-face-keys - pending-font-face-keys))) - {:thumbnails (persistent! thumbnails-acc) - :full (persistent! full-acc) - :text-font-state font-state-acc}))] - (perf/end-measure "set-objects") - (when on-shapes-ready (on-shapes-ready)) - (when (wasm/live?) - ;; Rebuild the tile index so _render knows which shapes - ;; map to which tiles after a page switch. - (h/call wasm/internal-module "_set_view_end") - (reset! view-interaction-active? false) - (process-pending shapes thumbnails full text-font-state - (fn [] - (if render-callback - (render-callback) - (request-render "set-objects-sync-complete")) - (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))) + (let [prepared (serialize-shape/serialize-shapes-batch! + shapes + {:include-layout? true + :include-fills-strokes? true} + use-shape) + total-shapes (count prepared) + {:keys [thumbnails full text-font-state]} + (loop [index 0 + thumbnails-acc (transient []) + full-acc (transient []) + font-state-acc empty-text-font-state] + (if (< index total-shapes) + (let [shape (nth prepared index) + {:keys [thumbnails full font-face-keys pending-font-face-keys]} + (set-object-host-attrs shape true :skip-fills-strokes? true)] + (recur (inc index) + (reduce conj! thumbnails-acc thumbnails) + (reduce conj! full-acc full) + (acc-text-font-state font-state-acc + (:id shape) + font-face-keys + pending-font-face-keys))) + {:thumbnails (persistent! thumbnails-acc) + :full (persistent! full-acc) + :text-font-state font-state-acc}))] + (perf/end-measure "set-objects") + (when on-shapes-ready (on-shapes-ready)) + (when (wasm/live?) + ;; Rebuild the tile index so _render knows which shapes + ;; map to which tiles after a page switch. + (h/call wasm/internal-module "_set_view_end") + (reset! view-interaction-active? false) + (process-pending shapes thumbnails full text-font-state + (fn [] + (if render-callback + (render-callback) + (request-render "set-objects-sync-complete")) + (ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))) (defn- shapes-in-tree-order "Returns shapes sorted in tree order (parents before children). diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index e3cad1058e..995ea816cd 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -9,12 +9,12 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.render-wasm.svg-derived :as svg-derived] [app.common.transit :as t] [app.common.types.shape :as shape] [app.common.types.shape.layout :as ctl] [app.main.refs :as refs] [app.render-wasm.api :as api] - [app.render-wasm.svg-filters :as svg-filters] [beicon.v2.core :as rx] [cljs.core :as c] [cuerdas.core :as str])) @@ -130,8 +130,8 @@ [shape k] (when (api/initialized?) (let [shape (case k - :svg-attrs (svg-filters/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs))) - (:fills :blur :shadow) (svg-filters/apply-svg-derived shape) + :svg-attrs (svg-derived/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs))) + (:fills :blur :shadow) (svg-derived/apply-svg-derived shape) shape) v (get shape k) id (get shape :id)] diff --git a/frontend/src/app/render_wasm/svg_filters.cljs b/frontend/src/app/render_wasm/svg_filters.cljs deleted file mode 100644 index 13cc8c00ed..0000000000 --- a/frontend/src/app/render_wasm/svg_filters.cljs +++ /dev/null @@ -1,98 +0,0 @@ -;; 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.render-wasm.svg-filters - (:require - [app.common.data :as d] - [app.common.data.macros :as dm] - [app.common.svg :as csvg] - [app.common.uuid :as uuid] - [app.render-wasm.svg-fills :as svg-fills])) - -(def ^:private drop-shadow-tags - #{:feOffset :feGaussianBlur :feColorMatrix}) - -(defn- find-filter-element - "Finds a filter element by tag in filter content." - [filter-content tag] - (some #(when (= tag (:tag %)) %) filter-content)) - -(defn- find-filter-def - [shape] - (let [filter-attr (or (dm/get-in shape [:svg-attrs :filter]) - (dm/get-in shape [:svg-attrs :style :filter])) - svg-defs (dm/get-prop shape :svg-defs)] - (when (and filter-attr svg-defs) - (let [filter-ids (csvg/extract-ids filter-attr)] - (some #(get svg-defs %) filter-ids))))) - -(defn- build-blur - [gaussian-blur] - (when gaussian-blur - {:id (uuid/next) - :type :layer-blur - ;; For layer blur the value matches stdDeviation directly - :value (-> (dm/get-in gaussian-blur [:attrs :stdDeviation]) - (d/parse-double 0)) - :hidden false})) - -(defn- build-drop-shadow - [filter-content drop-shadow-elements] - (let [offset-elem (find-filter-element filter-content :feOffset)] - (when (and offset-elem (seq drop-shadow-elements)) - (let [blur-elem (find-filter-element drop-shadow-elements :feGaussianBlur) - dx (-> (dm/get-in offset-elem [:attrs :dx]) - (d/parse-double 0)) - dy (-> (dm/get-in offset-elem [:attrs :dy]) - (d/parse-double 0)) - blur-value (if blur-elem - (-> (dm/get-in blur-elem [:attrs :stdDeviation]) - (d/parse-double 0) - (* 2)) - 0)] - [{:id (uuid/next) - :style :drop-shadow - :offset-x dx - :offset-y dy - :blur blur-value - :spread 0 - :hidden false - ;; TODO: parse feColorMatrix to extract color/opacity - :color {:color "#000000" :opacity 1}}])))) - -(defn apply-svg-filters - "Derives native blur/shadow from SVG filter definitions when the shape does - not already have them. The SVG attributes are left untouched so SVG fallback - rendering keeps working the same way as gradient fills." - [shape] - (let [existing-blur (:blur shape) - existing-shadow (:shadow shape)] - (if-let [filter-def (find-filter-def shape)] - (let [content (:content filter-def) - gaussian-blur (find-filter-element content :feGaussianBlur) - drop-shadow-elements (filter #(contains? drop-shadow-tags (:tag %)) content) - blur (or existing-blur (build-blur gaussian-blur)) - shadow (if (seq existing-shadow) - existing-shadow - (build-drop-shadow content drop-shadow-elements))] - (cond-> shape - blur (assoc :blur blur) - (seq shadow) (assoc :shadow shadow))) - shape))) - -(defn apply-svg-derived - "Applies SVG-derived effects (fills, blur, shadows) uniformly. - - Keeps user fills if present; otherwise derives from SVG. - - Converts SVG filters into native blur/shadow when needed. - - Always returns shape with :fills (possibly []) and blur/shadow keys." - [shape] - (let [shape' (apply-svg-filters shape) - fills (or (svg-fills/resolve-shape-fills shape') [])] - (assoc shape' - :fills fills - :blur (:blur shape') - :shadow (:shadow shape')))) - diff --git a/frontend/test/frontend_tests/render_wasm/serialization_test.cljs b/frontend/test/frontend_tests/render_wasm/serialization_test.cljs new file mode 100644 index 0000000000..c067793ab9 --- /dev/null +++ b/frontend/test/frontend_tests/render_wasm/serialization_test.cljs @@ -0,0 +1,164 @@ +;; 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.render-wasm.serialization-test + "Routing tests for `serialize-shape!` and `serialize-shapes-batch!`. + + A shared `tail-cases` table states, per shape, whether the svg-attrs/path + tail must fire. The pure `needs-shape-tail?` predicate is tested against + the table with no stubs; the two thin routing tests drive both serializers + from the same table with the FFI boundary stubbed. Guard drift between the + single and batch paths fails loudly. These tests assert routing only; + render output below the FFI line needs headed or exporter pixel runs." + (:require + [app.common.render-wasm.api.props :as props] + [app.common.render-wasm.api.upload :as upload] + [app.common.render-wasm.serialize-shape :as serialize-shape] + [app.common.render-wasm.svg-derived :as svg-derived] + [cljs.test :as t :include-macros true])) + +(defn- with-ffi-stubs* + "Stubs the single-arity FFI boundary fns (`flush-shapes-batch!`, + `set-shape-svg-attrs`, `set-shape-path-content`). `set-shape-upload!` is + deliberately left real: it is a trivial one-liner over the stubbed flush, + so the single test exercises its actual delegation and default opts. A + plain fn `set!` onto a multi-arity var breaks under the `:esm` test build + (call sites dispatch via `cljs$core$IFn$_invoke$arity$N`; see `mock/stub`)." + [stubs thunk] + (let [orig-flush upload/flush-shapes-batch! + orig-svg-attrs props/set-shape-svg-attrs + orig-path props/set-shape-path-content] + (set! upload/flush-shapes-batch! (:flush stubs)) + (set! props/set-shape-svg-attrs (:svg-attrs stubs)) + (set! props/set-shape-path-content (:path stubs)) + (try + (thunk) + (finally + (set! upload/flush-shapes-batch! orig-flush) + (set! props/set-shape-svg-attrs orig-svg-attrs) + (set! props/set-shape-path-content orig-path))))) + +(def ^:private tail-cases + "Shared spec for the svg-attrs/path tail. Each entry states whether the + tail must fire (`:tail?`) and which writes it must perform (`:svg?`, + `:path?`). Tests for single and batch serialization share this table." + [{:shape {:id (random-uuid) :type :frame} :tail? false :svg? false :path? false} + {:shape {:id (random-uuid) :type :rect} :tail? false :svg? false :path? false} + {:shape {:id (random-uuid) :type :rect :svg-attrs {:fill "blue"}} + :tail? true :svg? true :path? false} + {:shape {:id (random-uuid) :type :group :svg-attrs {:fill "blue"}} + :tail? true :svg? true :path? false} + {:shape {:id (random-uuid) :type :text :svg-attrs {:fill "blue"}} + :tail? true :svg? true :path? false} + {:shape {:id (random-uuid) :type :path :content {:type :path-content}} + :tail? true :svg? false :path? true} + {:shape {:id (random-uuid) :type :path} + :tail? false :svg? false :path? false} + {:shape {:id (random-uuid) :type :path + :content {:type :path-content} :svg-attrs {:fill "red"}} + :tail? true :svg? true :path? true} + {:shape {:id (random-uuid) :type :bool :content {:type :bool-content}} + :tail? true :svg? false :path? true} + {:shape {:id (random-uuid) :type :bool} + :tail? false :svg? false :path? false} + {:shape {:id (random-uuid) :type :text :content {:type :text-content}} + :tail? false :svg? false :path? false} + ;; svg-attrs fires the tail, but the inner type guard must still withhold + ;; the path write from text content. + {:shape {:id (random-uuid) :type :text + :svg-attrs {:fill "blue"} :content {:type :text-content}} + :tail? true :svg? true :path? false} + ;; svg-attrs alone fires the tail for path shapes; the svg write must not + ;; hide under the content check. + {:shape {:id (random-uuid) :type :path :svg-attrs {:fill "red"}} + :tail? true :svg? true :path? false}]) + +(defn- expected-events + [cases] + (into [] + (mapcat (fn [{:keys [shape tail? svg? path?]}] + (let [id (:id shape)] + (cond-> [] + tail? (conj [:select id]) + svg? (conj [:svg-attrs id (:svg-attrs shape)]) + path? (conj [:path id (:content shape)]))))) + cases)) + +(t/deftest tail-cases-flags-are-consistent + (doseq [{:keys [shape tail? svg? path?]} tail-cases] + (t/is (boolean? tail?) "tail? is a boolean") + (t/is (= tail? (boolean (or svg? path?))) "tail? matches the write flags") + (t/is (= svg? (some? (:svg-attrs shape))) "svg? matches the shape") + (t/is (= path? (boolean (and (contains? #{:path :bool} (:type shape)) + (some? (:content shape))))) + "path? matches the shape"))) + +(t/deftest needs-shape-tail-matches-spec-table + (doseq [{:keys [shape tail?]} tail-cases] + (t/is (= tail? (serialize-shape/needs-shape-tail? shape)) + (str "predicate matches spec for " (:type shape) + " svg? " (some? (:svg-attrs shape)) + " content? " (some? (:content shape)))))) + +(t/deftest serialize-shapes-batch-routes-flush-and-tail + (let [shapes (mapv :shape tail-cases) + opts {:include-layout? true :include-fills-strokes? true} + flush-calls (atom []) + events (atom []) + current (atom nil) + select-fn (fn [id] + (reset! current id) + (swap! events conj [:select id])) + stubs {:flush (fn [s o] (swap! flush-calls conj {:shapes s :opts o}) nil) + :svg-attrs (fn [attrs] (swap! events conj [:svg-attrs @current attrs]) nil) + :path (fn [content] (swap! events conj [:path @current content]) nil)} + result (with-ffi-stubs* stubs + #(serialize-shape/serialize-shapes-batch! shapes opts select-fn)) + flushed (first @flush-calls)] + (t/is (= 1 (count @flush-calls)) "exactly one flush") + (t/is (= opts (:opts flushed)) "flush carries passed opts") + (t/is (= (count shapes) (count (:shapes flushed))) "flush covers all shapes") + (t/is (= (mapv :id shapes) (mapv :id (:shapes flushed))) "order preserved") + (t/is (= (expected-events tail-cases) @events) + "select precedes each tail write, tails hit the right ids") + (t/is (= (mapv :id shapes) (mapv :id result)) "returns prepared in order"))) + +(t/deftest serialize-shape-routes-single-upload-and-tail + (doseq [{:keys [shape tail? svg? path?]} tail-cases] + (let [flush-calls (atom []) + svg-applied (atom []) + path-applied (atom []) + stubs {:flush (fn [s o] (swap! flush-calls conj {:shapes s :opts o}) nil) + :svg-attrs (fn [attrs] (swap! svg-applied conj attrs) nil) + :path (fn [content] (swap! path-applied conj content) nil)}] + (let [expected (svg-derived/apply-svg-derived shape) + result (with-ffi-stubs* stubs #(serialize-shape/serialize-shape! shape))] + (t/is (= [{:shapes [expected] :opts {:include-layout? false}}] @flush-calls) + (str "single upload carries derived shape without layout for " (:type shape))) + (t/is (= expected result) + (str "returns the derived shape for downstream host attrs for " (:type shape)))) + (t/is (= (if svg? [(:svg-attrs shape)] []) @svg-applied) + (str "svg-attrs write iff spec for " (:type shape))) + (t/is (= (if path? [(:content shape)] []) @path-applied) + (str "path write iff spec for " (:type shape))) + (t/is (= tail? (boolean (or (seq @svg-applied) (seq @path-applied)))) + (str "tail fires iff spec for " (:type shape)))))) + +(t/deftest serialize-shapes-batch-empty-batch-is-noop + (let [opts {:include-layout? true :include-fills-strokes? true} + flush-calls (atom []) + selected (atom []) + stubs {:flush (fn [s o] (swap! flush-calls conj {:shapes s :opts o}) nil) + :svg-attrs (fn [_] (t/is false "no svg-attrs write on empty batch") nil) + :path (fn [_] (t/is false "no path write on empty batch") nil)} + result (with-ffi-stubs* stubs + #(serialize-shape/serialize-shapes-batch! + [] + opts + (fn [id] (swap! selected conj id))))] + (t/is (= [] result) "returns empty prepared") + (t/is (empty? @flush-calls) "no flush on empty batch") + (t/is (empty? @selected) "no select on empty batch"))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index e4b3796282..85c980e34f 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -78,10 +78,12 @@ [frontend-tests.plugins.value-objects-test] [frontend-tests.render-dimensions-test] [frontend-tests.render-wasm.process-objects-test] + [frontend-tests.render-wasm.serialization-test] [frontend-tests.render-wasm.text-editor-apply-styles-test] [frontend-tests.render-wasm.text-editor-caret-color-test] [frontend-tests.router-test] [frontend-tests.svg-fills-test] + [frontend-tests.svg-filters-test] [frontend-tests.text-editor-paste-guard-test] [frontend-tests.tokens.copy-paste-props-test] [frontend-tests.tokens.import-export-test] @@ -205,10 +207,12 @@ 'frontend-tests.plugins.utils-test 'frontend-tests.plugins.value-objects-test 'frontend-tests.render-wasm.process-objects-test + 'frontend-tests.render-wasm.serialization-test 'frontend-tests.render-wasm.text-editor-apply-styles-test 'frontend-tests.render-wasm.text-editor-caret-color-test 'frontend-tests.router-test 'frontend-tests.svg-fills-test + 'frontend-tests.svg-filters-test 'frontend-tests.tokens.copy-paste-props-test 'frontend-tests.tokens.import-export-test 'frontend-tests.tokens.logic.token-actions-test diff --git a/frontend/test/frontend_tests/svg_fills_test.cljs b/frontend/test/frontend_tests/svg_fills_test.cljs index 1a0d4b66d1..cecb4ad3b8 100644 --- a/frontend/test/frontend_tests/svg_fills_test.cljs +++ b/frontend/test/frontend_tests/svg_fills_test.cljs @@ -6,7 +6,7 @@ (ns frontend-tests.svg-fills-test (:require - [app.render-wasm.svg-fills :as svg-fills] + [app.common.render-wasm.svg-derived :as svg-derived] [cljs.test :refer [deftest is testing]])) (def sample-shape @@ -28,7 +28,7 @@ :style "stop-color:#00ff00;stop-opacity:0"}}]}}}) (deftest builds-gradient-fill-from-svg-defs - (let [fills (svg-fills/svg-fill->fills sample-shape) + (let [fills (svg-derived/svg-fill->fills sample-shape) gradient (get-in (first fills) [:fill-color-gradient])] (testing "fallback fill is generated" (is (= 1 (count fills)))) @@ -40,7 +40,7 @@ (is (= 0.5 (:fill-opacity (first fills))))))) (deftest skips-when-no-svg-fill - (is (nil? (svg-fills/svg-fill->fills {:svg-attrs {:fill "none"}})))) + (is (nil? (svg-derived/svg-fill->fills {:svg-attrs {:fill "none"}})))) (def elliptical-shape {:selrect {:x 0 :y 0 :width 200 :height 100} @@ -61,7 +61,7 @@ :style "stop-color:#ffffff;stop-opacity:1"}}]}}}) (deftest builds-elliptical-radial-gradient-with-transform - (let [fills (svg-fills/svg-fill->fills elliptical-shape) + (let [fills (svg-derived/svg-fill->fills elliptical-shape) gradient (get-in (first fills) [:fill-color-gradient])] (testing "ellipse from gradientTransform is preserved" (is (= 1 (count fills))) @@ -75,22 +75,22 @@ (deftest resolve-shape-fills-prefers-existing-fills (let [fills [{:fill-color "#ff00ff" :fill-opacity 0.75}] - resolved (svg-fills/resolve-shape-fills {:fills fills})] + resolved (svg-derived/resolve-shape-fills {:fills fills})] (is (= fills resolved)))) (deftest resolve-shape-fills-falls-back-to-svg-fill - (let [resolved (svg-fills/resolve-shape-fills (assoc sample-shape :fills []))] - (is (= (svg-fills/svg-fill->fills sample-shape) resolved)))) + (let [resolved (svg-derived/resolve-shape-fills (assoc sample-shape :fills []))] + (is (= (svg-derived/svg-fill->fills sample-shape) resolved)))) (deftest resolve-shape-fills-defaults-to-black (is (= [{:fill-color "#000000" :fill-opacity 1}] - (svg-fills/resolve-shape-fills {:type :group - :svg-attrs {}})))) + (svg-derived/resolve-shape-fills {:type :group + :svg-attrs {}})))) (deftest resolve-shape-fills-accepts-hex-fill - (let [fills (svg-fills/resolve-shape-fills {:fills [] - :type :svg-raw - :svg-attrs {:fill "#fabada"}})] + (let [fills (svg-derived/resolve-shape-fills {:fills [] + :type :svg-raw + :svg-attrs {:fill "#fabada"}})] (is (= 1 (count fills))) (is (= "#fabada" (:fill-color (first fills)))))) diff --git a/frontend/test/frontend_tests/svg_filters_test.cljs b/frontend/test/frontend_tests/svg_filters_test.cljs index 80fd1b250a..a8b1fbb90b 100644 --- a/frontend/test/frontend_tests/svg_filters_test.cljs +++ b/frontend/test/frontend_tests/svg_filters_test.cljs @@ -6,7 +6,7 @@ (ns frontend-tests.svg-filters-test (:require - [app.render-wasm.svg-filters :as svg-filters] + [app.common.render-wasm.svg-derived :as svg-derived] [cljs.test :refer [deftest is testing]])) (def sample-filter-shape @@ -17,7 +17,7 @@ {:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]}}}) (deftest derives-blur-and-shadow-from-svg-filter - (let [shape (svg-filters/apply-svg-filters sample-filter-shape) + (let [shape (svg-derived/apply-svg-filters sample-filter-shape) blur (:blur shape) shadow (:shadow shape)] (testing "layer blur derived from feGaussianBlur" @@ -38,12 +38,12 @@ (deftest keeps-existing-native-filters (let [existing {:blur {:id :existing :type :layer-blur :value 1.0} :shadow [{:id :shadow :style :drop-shadow}]} - shape (svg-filters/apply-svg-filters (merge sample-filter-shape existing))] + shape (svg-derived/apply-svg-filters (merge sample-filter-shape existing))] (is (= (:blur existing) (:blur shape))) (is (= (:shadow existing) (:shadow shape))))) (deftest skips-when-no-filter-definition (let [shape {:svg-attrs {:fill "#fff"}} - result (svg-filters/apply-svg-filters shape)] + result (svg-derived/apply-svg-filters shape)] (is (= shape result))))