🐛 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."
(: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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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.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

View File

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

View File

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