🐛 Refactor batch serialization and fix derived svg-attrs in exporter (#11909)

* ♻️ Share structural batch upload through common helper

- Merge svg-filters and svg-fills to app.common.render-wasm.svg-derived
- Add serialize-shapes-batch! in common, shared by the sync and chunked
  workspace paths
- Add a routing test for the helper and wires the svg-filters test.

AI-assisted-by: muse-spark, GLM 5.3

* 🐛 Derive SVG effects inside single-shape serializer

- Single and batch paths: one svg effect derivation step
  owned by shared serializers.
- set-object forwards the derived shape to its host attrs,
  and the exporter reads the derived fills, so SVG-attr
  fills, blur and shadow render as in the frontend.
- Adds regression test to the exporter.

AI-assisted-by: muse-spark, GLM 5.3, deepseek-flash
This commit is contained in:
Miguel de Benito Delgado 2026-09-25 11:48:21 +02:00 committed by GitHub
parent 0255bed6c4
commit fa81a3f648
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 421 additions and 205 deletions

View File

@ -25,16 +25,52 @@
dispatching per changed key through the same underlying `props` setters." dispatching per changed key through the same underlying `props` setters."
(:require (:require
[app.common.render-wasm.api.props :as props] [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! (defn serialize-shape!
"Applies every host-independent WASM property of `shape`." "Applies every host-independent WASM property of `shape`."
[shape] [shape]
(let [type (get shape :type)] (let [shape (svg-derived/apply-svg-derived shape)]
(upload/set-shape-upload! shape {:include-layout? false}) (upload/set-shape-upload! shape {:include-layout? false})
(when (some? (get shape :svg-attrs)) (when (needs-shape-tail? shape)
(props/set-shape-svg-attrs (get shape :svg-attrs))) (write-shape-tail! shape))
(when (and (contains? #{:path :bool} type) (some? (get shape :content))) shape))
(props/set-shape-path-content (get shape :content)))))
(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))

View File

@ -4,7 +4,8 @@
;; ;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL ;; 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 (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
@ -12,6 +13,7 @@
[app.common.geom.rect :as grc] [app.common.geom.rect :as grc]
[app.common.svg :as csvg] [app.common.svg :as csvg]
[app.common.types.color :as clr] [app.common.types.color :as clr]
[app.common.uuid :as uuid]
[clojure.string :as str])) [clojure.string :as str]))
(def ^:private url-fill-pattern (def ^:private url-fill-pattern
@ -380,3 +382,86 @@
[{:fill-color "#000000" :fill-opacity 1}] [{:fill-color "#000000" :fill-opacity 1}]
:else []))) :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'))))

View File

@ -2,4 +2,7 @@
set -ex set -ex
pnpm install; pnpm install;
pushd ../render-wasm;
./build export;
popd
pnpm run test; pnpm run test;

View File

@ -28,13 +28,13 @@
host-specific parts are handled here: fills/strokes (image bytes are provisioned host-specific parts are handled here: fills/strokes (image bytes are provisioned
separately) and text content (fonts provisioned separately)." separately) and text content (fonts provisioned separately)."
[shape] [shape]
(let [type (get shape :type)] (let [prepared (serialize-shape/serialize-shape! shape)
(serialize-shape/serialize-shape! shape) type (:type prepared)]
(props/write-shape-fills! (get shape :fills)) (props/write-shape-fills! (:fills prepared))
(when-not (= type :group) (when-not (= type :group)
(props/write-shape-strokes! (get shape :strokes))) (props/write-shape-strokes! (:strokes prepared)))
(when (= type :text) (when (= type :text)
(text/set-shape-text! (get shape :content))))) (text/set-shape-text! (:content prepared)))))
(defn serialize-scene! (defn serialize-scene!
"Loads every shape of an `objects` map into the WASM design state. Resets the "Loads every shape of an `objects` map into the WASM design state. Resets the

View File

@ -7,7 +7,11 @@
(ns exporter-tests.renderer-svg-test (ns exporter-tests.renderer-svg-test
(:require (:require
[app.renderer.svg-gradient :as svg-gradient] [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 (def gradient-stops
[{"color" "#000000" "offset" 0 "opacity" 1} [{"color" "#000000" "offset" 0 "opacity" 1}
@ -23,3 +27,46 @@
"stops" gradient-stops}} "stops" gradient-stops}}
result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])] result (svg-gradient/data->gradient-def "text-id" ["#000001" gradient-data])]
(is (= element-name (get result "name"))))))) (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))))))

View File

@ -10,6 +10,7 @@
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt] [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 :as path]
[app.common.types.path.helpers :as path.helpers] [app.common.types.path.helpers :as path.helpers]
[app.main.data.helpers :as dsh] [app.main.data.helpers :as dsh]
@ -22,7 +23,6 @@
[app.main.data.workspace.path.tools :as tools] [app.main.data.workspace.path.tools :as tools]
[app.main.data.workspace.path.undo :as undo] [app.main.data.workspace.path.undo :as undo]
[app.main.streams :as ms] [app.main.streams :as ms]
[app.render-wasm.svg-fills :as svg-fills]
[app.util.mouse :as mse] [app.util.mouse :as mse]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[beicon.v2.operators :as rxo] [beicon.v2.operators :as rxo]
@ -885,7 +885,7 @@
"Resolves the fills inherited by the editing copy. "Resolves the fills inherited by the editing copy.
Frames stop group fill inheritance." Frames stop group fill inheritance."
[shape objects] [shape objects]
(let [own (svg-fills/resolve-shape-fills shape)] (let [own (svg-derived/resolve-shape-fills shape)]
(if (seq own) (if (seq own)
own own
(loop [parent-id (:parent-id shape) (loop [parent-id (:parent-id shape)
@ -897,7 +897,7 @@
(let [parent (get objects parent-id)] (let [parent (get objects parent-id)]
(cond (cond
(nil? parent) [] (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) (if (seq fills)
fills fills
(recur (:parent-id parent) (recur (:parent-id parent)

View File

@ -17,7 +17,6 @@
[app.common.logging :as log] [app.common.logging :as log]
[app.common.math :as mth] [app.common.math :as mth]
[app.common.render-wasm.api.props :as props] [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.helpers :as h]
[app.common.render-wasm.mem :as mem] [app.common.render-wasm.mem :as mem]
[app.common.render-wasm.mem.heap32 :as mem.h32] [app.common.render-wasm.mem.heap32 :as mem.h32]
@ -50,7 +49,6 @@
[app.render-wasm.gesture :as wasm-gesture] [app.render-wasm.gesture :as wasm-gesture]
[app.render-wasm.performance :as perf] [app.render-wasm.performance :as perf]
[app.render-wasm.rulers-state :as rulers-state] [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.render-wasm.text-editor :as text-editor]
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.dom :as dom] [app.util.dom :as dom]
@ -1645,11 +1643,10 @@
{:thumbnails [] :full [] :font-face-keys #{} :pending-font-face-keys #{}} {:thumbnails [] :full [] :font-face-keys #{} :pending-font-face-keys #{}}
(do (do
(perf/begin-measure "set-object") (perf/begin-measure "set-object")
(let [shape (svg-filters/apply-svg-derived shape)] (let [prepared (serialize-shape/serialize-shape! shape)
(serialize-shape/serialize-shape! shape) result (set-object-host-attrs prepared false)]
(let [result (set-object-host-attrs shape false)] (perf/end-measure "set-object")
(perf/end-measure "set-object") result))))
result)))))
(defn- update-text-layouts (defn- update-text-layouts
"Synchronously update text layouts for all shapes and send rect updates "Synchronously update text layouts for all shapes and send rect updates
@ -1814,24 +1811,11 @@
end-index (min total (+ start-index BATCH_MAX_SHAPES)) end-index (min total (+ start-index BATCH_MAX_SHAPES))
chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes)) chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes))
start-index end-index)) start-index end-index))
prepared (mapv svg-filters/apply-svg-derived chunk)] prepared (serialize-shape/serialize-shapes-batch!
chunk
;; One multi-shape structural upload (base+children+blur+shadows+flex+item+fills+strokes). {:include-layout? true
(when (seq prepared) :include-fills-strokes? true}
(upload/flush-shapes-batch! prepared {:include-layout? true use-shape)]
: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))))))
(loop [xs prepared (loop [xs prepared
t-acc (transient thumbnails-acc) t-acc (transient thumbnails-acc)
@ -1977,53 +1961,44 @@
(defn- set-objects-sync (defn- set-objects-sync
"Synchronously process all shapes (for small shape counts)." "Synchronously process all shapes (for small shape counts)."
[shapes render-callback on-shapes-ready] [shapes render-callback on-shapes-ready]
(let [prepared (mapv svg-filters/apply-svg-derived shapes)] (let [prepared (serialize-shape/serialize-shapes-batch!
(when (seq prepared) shapes
(upload/flush-shapes-batch! prepared {:include-layout? true {:include-layout? true
:include-fills-strokes? true})) :include-fills-strokes? true}
(doseq [shape prepared] use-shape)
(let [id (dm/get-prop shape :id) total-shapes (count prepared)
type (dm/get-prop shape :type)] {:keys [thumbnails full text-font-state]}
(when (or (some? (get shape :svg-attrs)) (loop [index 0
(and (contains? #{:path :bool} type) (some? (get shape :content)))) thumbnails-acc (transient [])
(use-shape id) full-acc (transient [])
(when (some? (get shape :svg-attrs)) font-state-acc empty-text-font-state]
(props/set-shape-svg-attrs (get shape :svg-attrs))) (if (< index total-shapes)
(when (and (contains? #{:path :bool} type) (some? (get shape :content))) (let [shape (nth prepared index)
(props/set-shape-path-content (get shape :content)))))) {:keys [thumbnails full font-face-keys pending-font-face-keys]}
(let [total-shapes (count prepared) (set-object-host-attrs shape true :skip-fills-strokes? true)]
{:keys [thumbnails full text-font-state]} (recur (inc index)
(loop [index 0 (reduce conj! thumbnails-acc thumbnails)
thumbnails-acc (transient []) (reduce conj! full-acc full)
full-acc (transient []) (acc-text-font-state font-state-acc
font-state-acc empty-text-font-state] (:id shape)
(if (< index total-shapes) font-face-keys
(let [shape (nth prepared index) pending-font-face-keys)))
{:keys [thumbnails full font-face-keys pending-font-face-keys]} {:thumbnails (persistent! thumbnails-acc)
(set-object-host-attrs shape true :skip-fills-strokes? true)] :full (persistent! full-acc)
(recur (inc index) :text-font-state font-state-acc}))]
(reduce conj! thumbnails-acc thumbnails) (perf/end-measure "set-objects")
(reduce conj! full-acc full) (when on-shapes-ready (on-shapes-ready))
(acc-text-font-state font-state-acc (when (wasm/live?)
(:id shape) ;; Rebuild the tile index so _render knows which shapes
font-face-keys ;; map to which tiles after a page switch.
pending-font-face-keys))) (h/call wasm/internal-module "_set_view_end")
{:thumbnails (persistent! thumbnails-acc) (reset! view-interaction-active? false)
:full (persistent! full-acc) (process-pending shapes thumbnails full text-font-state
:text-font-state font-state-acc}))] (fn []
(perf/end-measure "set-objects") (if render-callback
(when on-shapes-ready (on-shapes-ready)) (render-callback)
(when (wasm/live?) (request-render "set-objects-sync-complete"))
;; Rebuild the tile index so _render knows which shapes (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
;; 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 (defn- shapes-in-tree-order
"Returns shapes sorted in tree order (parents before children). "Returns shapes sorted in tree order (parents before children).

View File

@ -9,12 +9,12 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.files.helpers :as cfh] [app.common.files.helpers :as cfh]
[app.common.render-wasm.svg-derived :as svg-derived]
[app.common.transit :as t] [app.common.transit :as t]
[app.common.types.shape :as shape] [app.common.types.shape :as shape]
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.render-wasm.api :as api] [app.render-wasm.api :as api]
[app.render-wasm.svg-filters :as svg-filters]
[beicon.v2.core :as rx] [beicon.v2.core :as rx]
[cljs.core :as c] [cljs.core :as c]
[cuerdas.core :as str])) [cuerdas.core :as str]))
@ -130,8 +130,8 @@
[shape k] [shape k]
(when (api/initialized?) (when (api/initialized?)
(let [shape (case k (let [shape (case k
:svg-attrs (svg-filters/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs))) :svg-attrs (svg-derived/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs)))
(:fills :blur :shadow) (svg-filters/apply-svg-derived shape) (:fills :blur :shadow) (svg-derived/apply-svg-derived shape)
shape) shape)
v (get shape k) v (get shape k)
id (get shape :id)] id (get shape :id)]

View File

@ -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'))))

View File

@ -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")))

View File

@ -78,10 +78,12 @@
[frontend-tests.plugins.value-objects-test] [frontend-tests.plugins.value-objects-test]
[frontend-tests.render-dimensions-test] [frontend-tests.render-dimensions-test]
[frontend-tests.render-wasm.process-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-apply-styles-test]
[frontend-tests.render-wasm.text-editor-caret-color-test] [frontend-tests.render-wasm.text-editor-caret-color-test]
[frontend-tests.router-test] [frontend-tests.router-test]
[frontend-tests.svg-fills-test] [frontend-tests.svg-fills-test]
[frontend-tests.svg-filters-test]
[frontend-tests.text-editor-paste-guard-test] [frontend-tests.text-editor-paste-guard-test]
[frontend-tests.tokens.copy-paste-props-test] [frontend-tests.tokens.copy-paste-props-test]
[frontend-tests.tokens.import-export-test] [frontend-tests.tokens.import-export-test]
@ -205,10 +207,12 @@
'frontend-tests.plugins.utils-test 'frontend-tests.plugins.utils-test
'frontend-tests.plugins.value-objects-test 'frontend-tests.plugins.value-objects-test
'frontend-tests.render-wasm.process-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-apply-styles-test
'frontend-tests.render-wasm.text-editor-caret-color-test 'frontend-tests.render-wasm.text-editor-caret-color-test
'frontend-tests.router-test 'frontend-tests.router-test
'frontend-tests.svg-fills-test 'frontend-tests.svg-fills-test
'frontend-tests.svg-filters-test
'frontend-tests.tokens.copy-paste-props-test 'frontend-tests.tokens.copy-paste-props-test
'frontend-tests.tokens.import-export-test 'frontend-tests.tokens.import-export-test
'frontend-tests.tokens.logic.token-actions-test 'frontend-tests.tokens.logic.token-actions-test

View File

@ -6,7 +6,7 @@
(ns frontend-tests.svg-fills-test (ns frontend-tests.svg-fills-test
(:require (:require
[app.render-wasm.svg-fills :as svg-fills] [app.common.render-wasm.svg-derived :as svg-derived]
[cljs.test :refer [deftest is testing]])) [cljs.test :refer [deftest is testing]]))
(def sample-shape (def sample-shape
@ -28,7 +28,7 @@
:style "stop-color:#00ff00;stop-opacity:0"}}]}}}) :style "stop-color:#00ff00;stop-opacity:0"}}]}}})
(deftest builds-gradient-fill-from-svg-defs (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])] gradient (get-in (first fills) [:fill-color-gradient])]
(testing "fallback fill is generated" (testing "fallback fill is generated"
(is (= 1 (count fills)))) (is (= 1 (count fills))))
@ -40,7 +40,7 @@
(is (= 0.5 (:fill-opacity (first fills))))))) (is (= 0.5 (:fill-opacity (first fills)))))))
(deftest skips-when-no-svg-fill (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 (def elliptical-shape
{:selrect {:x 0 :y 0 :width 200 :height 100} {:selrect {:x 0 :y 0 :width 200 :height 100}
@ -61,7 +61,7 @@
:style "stop-color:#ffffff;stop-opacity:1"}}]}}}) :style "stop-color:#ffffff;stop-opacity:1"}}]}}})
(deftest builds-elliptical-radial-gradient-with-transform (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])] gradient (get-in (first fills) [:fill-color-gradient])]
(testing "ellipse from gradientTransform is preserved" (testing "ellipse from gradientTransform is preserved"
(is (= 1 (count fills))) (is (= 1 (count fills)))
@ -75,22 +75,22 @@
(deftest resolve-shape-fills-prefers-existing-fills (deftest resolve-shape-fills-prefers-existing-fills
(let [fills [{:fill-color "#ff00ff" :fill-opacity 0.75}] (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)))) (is (= fills resolved))))
(deftest resolve-shape-fills-falls-back-to-svg-fill (deftest resolve-shape-fills-falls-back-to-svg-fill
(let [resolved (svg-fills/resolve-shape-fills (assoc sample-shape :fills []))] (let [resolved (svg-derived/resolve-shape-fills (assoc sample-shape :fills []))]
(is (= (svg-fills/svg-fill->fills sample-shape) resolved)))) (is (= (svg-derived/svg-fill->fills sample-shape) resolved))))
(deftest resolve-shape-fills-defaults-to-black (deftest resolve-shape-fills-defaults-to-black
(is (= [{:fill-color "#000000" :fill-opacity 1}] (is (= [{:fill-color "#000000" :fill-opacity 1}]
(svg-fills/resolve-shape-fills {:type :group (svg-derived/resolve-shape-fills {:type :group
:svg-attrs {}})))) :svg-attrs {}}))))
(deftest resolve-shape-fills-accepts-hex-fill (deftest resolve-shape-fills-accepts-hex-fill
(let [fills (svg-fills/resolve-shape-fills {:fills [] (let [fills (svg-derived/resolve-shape-fills {:fills []
:type :svg-raw :type :svg-raw
:svg-attrs {:fill "#fabada"}})] :svg-attrs {:fill "#fabada"}})]
(is (= 1 (count fills))) (is (= 1 (count fills)))
(is (= "#fabada" (:fill-color (first fills)))))) (is (= "#fabada" (:fill-color (first fills))))))

View File

@ -6,7 +6,7 @@
(ns frontend-tests.svg-filters-test (ns frontend-tests.svg-filters-test
(:require (:require
[app.render-wasm.svg-filters :as svg-filters] [app.common.render-wasm.svg-derived :as svg-derived]
[cljs.test :refer [deftest is testing]])) [cljs.test :refer [deftest is testing]]))
(def sample-filter-shape (def sample-filter-shape
@ -17,7 +17,7 @@
{:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]}}}) {:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]}}})
(deftest derives-blur-and-shadow-from-svg-filter (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) blur (:blur shape)
shadow (:shadow shape)] shadow (:shadow shape)]
(testing "layer blur derived from feGaussianBlur" (testing "layer blur derived from feGaussianBlur"
@ -38,12 +38,12 @@
(deftest keeps-existing-native-filters (deftest keeps-existing-native-filters
(let [existing {:blur {:id :existing :type :layer-blur :value 1.0} (let [existing {:blur {:id :existing :type :layer-blur :value 1.0}
:shadow [{:id :shadow :style :drop-shadow}]} :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 (= (:blur existing) (:blur shape)))
(is (= (:shadow existing) (:shadow shape))))) (is (= (:shadow existing) (:shadow shape)))))
(deftest skips-when-no-filter-definition (deftest skips-when-no-filter-definition
(let [shape {:svg-attrs {:fill "#fff"}} (let [shape {:svg-attrs {:fill "#fff"}}
result (svg-filters/apply-svg-filters shape)] result (svg-derived/apply-svg-filters shape)]
(is (= shape result)))) (is (= shape result))))