Compare commits

..
Author SHA1 Message Date
Andrés Moya ccf7891479 wip 2026-09-08 12:20:03 +02:00
Andrés Moya deb0583029 🔧 Add tests and validations to variant functions 2026-09-08 11:54:38 +02:00
Andrés Moya a5b19118b9 💄 Rename props -> properties 2026-09-08 11:54:38 +02:00
Andrés Moya 0f66caf542 🔧 Validate changes immediately after processing them 2026-09-08 11:54:38 +02:00
41 changed files with 731 additions and 1361 deletions

No files matched your search

+87 -44
View File
@@ -6,6 +6,7 @@
(ns app.common.files.changes
(:require
#?(:cljs [app.common.files.validate :as val])
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
@@ -428,7 +429,14 @@
[:set-base-font-size
[:map {:title "ModBaseFontSize"}
[:type [:= :set-base-font-size]]
[:base-font-size :string]]]])
[:base-font-size :string]]]
[:validate-shapes
[:map {:title "ValidateShapesChange"}
[:type [:= :validate-shapes]]
[:page-id ::sm/uuid]
[:shape-ids [:vector ::sm/uuid]]
[:context :string]]]])
(def schema:changes
[:sequential {:gen/max 5 :gen/min 1} schema:change])
@@ -464,7 +472,7 @@
to the processor backend."
nil)
(defmulti process-change (fn [_ change] (:type change)))
(defmulti process-change (fn [_ change _] (:type change)))
(defmulti process-operation (fn [_ op] (:type op)))
;; Changes Processing Impl
@@ -496,22 +504,25 @@
(defn process-changes
([data items]
(process-changes data items true))
(process-changes data items true {}))
([data items verify?]
(process-changes data items verify? {}))
([data items verify? libraries]
;; When verify? false we spec the schema validation. Currently used
;; to make just 1 validation even if the changes are applied twice
(when verify?
(check-changes items))
(binding [*touched-changes* (volatile! #{})]
(let [result (reduce #(or (process-change %1 %2) %1) data items)]
(let [result (reduce #(or (process-change %1 %2 libraries) %1) data items)]
(reduce process-touched-change result @*touched-changes*)))))
;; --- Comment Threads
(defmethod process-change :set-comment-thread-position
[data {:keys [page-id comment-thread-id position frame-id]}]
[data {:keys [page-id comment-thread-id position frame-id]} _]
(d/update-in-when data [:pages-index page-id]
(fn [page]
(if (and position frame-id)
@@ -524,7 +535,7 @@
;; --- Guides
(defmethod process-change :set-guide
[data {:keys [page-id id params]}]
[data {:keys [page-id id params]} _]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
@@ -540,7 +551,7 @@
;; --- Flows
(defmethod process-change :set-flow
[data {:keys [page-id id params]}]
[data {:keys [page-id id params]} _]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
@@ -556,7 +567,7 @@
;; --- Grids
(defmethod process-change :set-default-grid
[data {:keys [page-id grid-type params]}]
[data {:keys [page-id grid-type params]} _]
(if (nil? params)
(d/update-in-when data [:pages-index page-id]
(fn [page]
@@ -593,7 +604,7 @@
(update state :media-refs into xform media-refs)))
(defmethod process-change :add-obj
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]}]
[data {:keys [id obj page-id component-id frame-id parent-id index ignore-touched]} _]
;; NOTE: we only perform hard validation on backend
#?(:clj (validate-shape obj page-id))
@@ -628,7 +639,7 @@
objects))
(defmethod process-change :mod-obj
[data {:keys [page-id component-id] :as change}]
[data {:keys [page-id component-id] :as change} _]
(if page-id
(d/update-in-when data [:pages-index page-id :objects] process-operations change)
(d/update-in-when data [:components component-id :objects] process-operations change)))
@@ -658,19 +669,19 @@
objects))
(defmethod process-change :reorder-children
[data {:keys [page-id component-id] :as change}]
[data {:keys [page-id component-id] :as change} _]
(if page-id
(d/update-in-when data [:pages-index page-id :objects] process-children-reordering change)
(d/update-in-when data [:components component-id :objects] process-children-reordering change)))
(defmethod process-change :del-obj
[data {:keys [page-id component-id id ignore-touched]}]
[data {:keys [page-id component-id id ignore-touched]} _]
(if page-id
(d/update-in-when data [:pages-index page-id] ctst/delete-shape id ignore-touched)
(d/update-in-when data [:components component-id] ctst/delete-shape id ignore-touched)))
(defmethod process-change :fix-obj
[data {:keys [page-id component-id id] :as params}]
[data {:keys [page-id component-id id] :as params} _]
(letfn [(fix-container [container]
(case (:fix params :broken-children)
:broken-children (ctst/fix-broken-children container id)
@@ -682,7 +693,7 @@
(d/update-in-when data [:components component-id] fix-container))))
(defmethod process-change :reg-objects
[data {:keys [page-id component-id shapes]}]
[data {:keys [page-id component-id shapes]} _]
;; FIXME: Improve performance
(letfn [(reg-objects [objects]
(let [lookup (d/getf objects)
@@ -734,7 +745,7 @@
(defmethod process-change :mov-objects
;; FIXME: ignore-touched is no longer used, so we can consider it deprecated
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]}]
[data {:keys [parent-id shapes index page-id component-id #_ignore-touched after-shape allow-altering-copies syncing]} _]
(letfn [(calculate-invalid-targets [objects shape-id]
(let [reduce-fn #(into %1 (calculate-invalid-targets objects %2))]
(->> (get-in objects [shape-id :shapes])
@@ -849,7 +860,7 @@
(d/update-in-when data [:components component-id :objects] move-objects))))
(defmethod process-change :add-page
[data {:keys [id name page]}]
[data {:keys [id name page]} _]
(when (and id name page)
(ex/raise :type :conflict
:hint "id+name or page should be provided, never both"))
@@ -859,7 +870,7 @@
(ctpl/add-page data page)))
(defmethod process-change :mod-page
[data {:keys [id] :as params}]
[data {:keys [id] :as params} _]
(d/update-in-when data [:pages-index id]
(fn [page]
(let [name (get params :name)
@@ -889,7 +900,7 @@
(dissoc :pixel-grid-opacity))))))
(defmethod process-change :set-plugin-data
[data {:keys [object-type object-id page-id namespace key value]}]
[data {:keys [object-type object-id page-id namespace key value]} _]
(letfn [(update-fn [data]
(if (some? value)
(assoc-in data [:plugin-data namespace key] value)
@@ -915,83 +926,83 @@
(d/update-in-when data [:components object-id] update-fn))))
(defmethod process-change :del-page
[data {:keys [id]}]
[data {:keys [id]} _]
(ctpl/delete-page data id))
(defmethod process-change :mov-page
[data {:keys [id index]}]
[data {:keys [id index]} _]
(update data :pages d/insert-at-index index [id]))
(defmethod process-change :add-color
[data {:keys [color]}]
[data {:keys [color]} _]
(ctl/add-color data color))
(defmethod process-change :mod-color
[data {:keys [color]}]
[data {:keys [color]} _]
(ctl/set-color data color))
(defmethod process-change :del-color
[data {:keys [id]}]
[data {:keys [id]} _]
(ctl/delete-color data id))
;; -- Media
(defmethod process-change :add-media
[data {:keys [object]}]
[data {:keys [object]} _]
(update data :media assoc (:id object) object))
(defmethod process-change :mod-media
[data {:keys [object]}]
[data {:keys [object]} _]
(d/update-in-when data [:media (:id object)] merge object))
(defmethod process-change :del-media
[data {:keys [id]}]
[data {:keys [id]} _]
(d/update-when data :media dissoc id))
;; -- Components
(defmethod process-change :add-component
[data params]
[data params _]
(ctkl/add-component data params))
(defmethod process-change :mod-component
[data params]
[data params _]
(ctkl/mod-component data params))
(defmethod process-change :del-component
[data {:keys [id skip-undelete? delta]}]
[data {:keys [id skip-undelete? delta]} _]
(ctf/delete-component data id skip-undelete? delta))
(defmethod process-change :restore-component
[data {:keys [id page-id]}]
[data {:keys [id page-id]} _]
(ctf/restore-component data id page-id))
(defmethod process-change :purge-component
[data {:keys [id]}]
[data {:keys [id]} _]
(ctf/purge-component data id))
;; -- Typography
(defmethod process-change :add-typography
[data {:keys [typography]}]
[data {:keys [typography]} _]
(ctyl/add-typography data typography))
(defmethod process-change :mod-typography
[data {:keys [typography]}]
[data {:keys [typography]} _]
(ctyl/update-typography data (:id typography) merge typography))
(defmethod process-change :del-typography
[data {:keys [id]}]
[data {:keys [id]} _]
(ctyl/delete-typography data id))
;; -- Design Tokens
(defmethod process-change :set-tokens-lib
[data {:keys [tokens-lib]}]
[data {:keys [tokens-lib]} _]
(assoc data :tokens-lib tokens-lib))
(defmethod process-change :set-token
[data {:keys [set-id token-id attrs]}]
[data {:keys [set-id token-id attrs]} _]
(update data :tokens-lib
(fn [lib]
(let [lib' (ctob/ensure-tokens-lib lib)]
@@ -1008,7 +1019,7 @@
(ctob/make-token (merge prev-token attrs)))))))))
(defmethod process-change :set-token-set
[data {:keys [id attrs]}]
[data {:keys [id attrs]} _]
(update data :tokens-lib
(fn [lib]
(let [lib' (ctob/ensure-tokens-lib lib)]
@@ -1023,7 +1034,7 @@
(ctob/update-set lib' id (fn [_] (ctob/make-token-set attrs))))))))
(defmethod process-change :set-token-theme
[data {:keys [id attrs]}]
[data {:keys [id attrs]} _]
(update data :tokens-lib
(fn [lib]
(let [lib' (ctob/ensure-tokens-lib lib)]
@@ -1041,35 +1052,67 @@
(ctob/make-token-theme (merge prev-token-theme attrs)))))))))
(defmethod process-change :set-active-token-themes
[data {:keys [theme-paths]}]
[data {:keys [theme-paths]} _]
(update data :tokens-lib #(-> % (ctob/ensure-tokens-lib)
(ctob/set-active-themes theme-paths))))
(defmethod process-change :rename-token-set-group
[data {:keys [set-group-path set-group-fname]}]
[data {:keys [set-group-path set-group-fname]} _]
(update data :tokens-lib (fn [lib]
(-> lib
(ctob/ensure-tokens-lib)
(ctob/rename-set-group set-group-path set-group-fname)))))
(defmethod process-change :move-token-set
[data {:keys [from-path to-path before-path before-group] :as changes}]
[data {:keys [from-path to-path before-path before-group] :as changes} _]
(update data :tokens-lib #(-> %
(ctob/ensure-tokens-lib)
(ctob/move-set from-path to-path before-path before-group))))
(defmethod process-change :move-token-set-group
[data {:keys [from-path to-path before-path before-group]}]
[data {:keys [from-path to-path before-path before-group]} _]
(update data :tokens-lib #(-> %
(ctob/ensure-tokens-lib)
(ctob/move-set-group from-path to-path before-path before-group))))
;; === Design Tokens configuration
;; --- Design Tokens configuration
(defmethod process-change :set-base-font-size
[data {:keys [base-font-size]}]
[data {:keys [base-font-size]} _]
(ctf/set-base-font-size data base-font-size))
;; --- Validate Shapes
#?(:clj
(defmethod process-change :validate-shapes
[data _ _]
data))
#?(:cljs
(defmethod process-change :validate-shapes
[data {:keys [page-id shape-ids context]} libraries]
(if libraries
(println "Validating shapes: \n"
" page-id:" (str page-id) "\n"
" shape-ids:" (str shape-ids) "\n"
" context:" context)
(let [file {:data data :id uuid/zero}
errors (reduce (fn [acc shape-id]
(if-let [page (ctpl/get-page data page-id)]
(let [page-errors (val/validate-shape shape-id file page libraries)]
(if (seq page-errors)
(into acc page-errors)
acc))
acc))
[]
shape-ids)]
(when (seq errors)
(ex/raise :type :validation
:code :referential-integrity
:hint (str "error on validating shapes: " context)
:details errors))
data))
data))
;; === Operations
@@ -1203,7 +1203,6 @@
[changes]
(::page-id (meta changes)))
(defn set-text-content
[changes id content prev-content]
(assert-page-id! changes)
@@ -1224,3 +1223,12 @@
(-> changes
(update :redo-changes conj redo-change)
(update :undo-changes conj undo-change))))
;; Validate Shapes
(defn validate-shapes
[changes page-id shape-ids context]
(update changes :redo-changes conj {:type :validate-shapes
:page-id page-id
:shape-ids (vec shape-ids)
:context context}))
+12 -3
View File
@@ -10,7 +10,6 @@
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.files.variant :as cfv]
[app.common.path-names :as cpn]
[app.common.schema :as sm]
[app.common.types.component :as ctk]
@@ -569,7 +568,17 @@
objects (:objects page)
file-data (:data file)
first-child (get objects (first shapes))
prop-names (cfv/extract-properties-names first-child file-data)]
extract-properties-names
(fn [shape]
;; Get the names of the properties of the shape's component
(->> shape
(#(ctkl/get-component file-data (:component-id %) true))
:variant-properties
(map :name)))
prop-names (extract-properties-names first-child)]
(run! (fn [child-id]
(when-let [child (get objects child-id)]
(if (not (ctk/is-variant? child))
@@ -583,7 +592,7 @@
(str/ffmt "Main instance in variant % should have the variant-id of the container but has %" (:id child) (:variant-id child))
child file page
:variant-id shape-id))
(when (not= prop-names (cfv/extract-properties-names child file-data))
(when (not= prop-names (extract-properties-names child))
(report-error :invalid-variant-properties
(str/ffmt "Variant % has invalid properties %" (:id child) (vec prop-names))
child file page
+6 -13
View File
@@ -6,8 +6,8 @@
(ns app.common.files.variant
(:require
[app.common.data.macros :as dm]
[app.common.types.component :as ctc]
[app.common.types.components-list :as ctcl]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.variant :as ctv]))
(defn find-variant-components
@@ -25,16 +25,9 @@
;; We can't simply filter components, because we need to maintain the order
(->> (dm/get-in objects [variant-id :shapes])
(map #(dm/get-in objects [% :component-id]))
(map #(ctcl/get-component data % true))
(map #(ctkl/get-component data % true))
reverse)))
(defn extract-properties-names
[shape data]
(->> shape
(#(ctcl/get-component data (:component-id %) true))
:variant-properties
(map :name)))
(defn extract-properties-values
"Get a map of properties associated to their possible values"
[data objects variant-id]
@@ -74,10 +67,10 @@
(defn get-primary-component
[data component-id]
(when-let [component (ctcl/get-component data component-id)]
(if (ctc/is-variant? component)
(when-let [component (ctkl/get-component data component-id)]
(if (ctk/is-variant? component)
(->> component
(get-primary-variant data)
:component-id
(ctcl/get-component data))
(ctkl/get-component data))
component)))
+31 -5
View File
@@ -290,8 +290,11 @@
duplicated-parent?
(->> ids-map vals (some #(= % (:parent-id first-shape))))
grid-parent?
(and (ctsl/grid-layout? objects (:parent-id first-shape)) (not duplicated-parent?))
changes
(if (and (ctsl/grid-layout? objects (:parent-id first-shape)) (not duplicated-parent?))
(if grid-parent?
(let [target-cell (-> position meta :cell)
[row column]
@@ -313,7 +316,19 @@
changes
(reduce #(pcb/add-object %1 %2 {:ignore-touched true})
changes
(rest new-shapes))]
(rest new-shapes))
ids-to-validate (cond-> [(:id first-shape)]
grid-parent?
(conj (:parent-id first-shape)))
changes (if (seq ids-to-validate)
(pcb/validate-shapes changes
(:id page)
ids-to-validate
(str "generate-instantiate-component: " component-id
" under parent-id" (or parent-id " root")))
changes)]
[new-shape changes])))
@@ -3123,7 +3138,6 @@
;; we calculate a new one because the components will have created new shapes.
ids-map (into {} (map #(vector % (uuid/next))) all-ids)
;; If there is an alt-duplication we change to root
;; For variants so the copy is made as a child of root
;; This is because inside a variant-container can't be a copy
@@ -3135,7 +3149,6 @@
(assoc :parent-id uuid/zero :frame-id uuid/zero)))
shapes)
changes (-> changes
(pcb/with-page page)
(pcb/with-objects all-objects)
@@ -3165,7 +3178,20 @@
(comp
(filter #(= :add-obj (:type %)))
(map #(vector (:old-id %) (-> % :obj :id))))
(:redo-changes changes))]
(:redo-changes changes))
copied-components
(ctn/get-all-instance-roots (:objects page) ids)
ids-to-validate
(map #(get ids-map % %) copied-components)
changes (if (seq ids-to-validate)
(pcb/validate-shapes changes
(:id page)
ids-to-validate
(cond-> (str "generate-duplicate-changes: " ids)))
changes)]
(-> changes
(generate-duplicate-flows shapes page ids-map)
+29 -5
View File
@@ -75,7 +75,7 @@
(reduce check-shape changes mod-obj-changes)))
(defn generate-update-shapes
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation?]}]
[changes ids update-fn objects {:keys [attrs changed-sub-attr ignore-tree ignore-touched with-objects? translation? extra-context]}]
(let [changes (reduce
(fn [changes id]
(let [opts {:attrs attrs
@@ -96,7 +96,19 @@
(pcb/reorder-grid-children ids))
(not ignore-touched)
(generate-unapply-tokens objects changed-sub-attr))]
(generate-unapply-tokens objects changed-sub-attr))
page-id (pcb/get-page-id changes)
modified-components (ctn/get-all-instance-roots objects ids)
changes (if (and page-id (seq modified-components))
(pcb/validate-shapes changes
page-id
modified-components
(cond-> (str "generate-update-shapes: " ids " " attrs)
(some? extra-context)
(str " \n -> from " extra-context)))
changes)]
changes))
(defn- generate-update-shape-flags
@@ -248,8 +260,8 @@
page-id (pcb/get-page-id changes)
page (or (pcb/get-page changes)
(ctpl/get-page data page-id))
ids (cfh/clean-loops objects ids)
in-component-copy?
(fn [shape-id]
;; Look for shapes that are inside a component copy, but are
@@ -258,7 +270,7 @@
;; If we want to specifically allow altering the copies, this is
;; a special case, like a component swap, in which case we want
;; to delete the old shape
(let [shape (get objects shape-id)]
(let [shape (get objects shape-id)]
(and (ctn/has-any-copy-parent? objects shape)
(not allow-altering-copies))))
@@ -437,7 +449,19 @@
(into []
(remove #(and (ctsi/has-destination %)
(id-to-delete? (:destination %))))
interactions))))))]
interactions))))))
modified-components (ctn/get-all-instance-roots objects (disj all-parents uuid/zero))
;; There is no need to validate deleted objects. Probably also no need to validate hidden or unmasked objects,
;; but we may think of it
changes (if (seq modified-components)
(pcb/validate-shapes changes
page-id
modified-components
(str "generate-delete-shapes: " ids))
changes)]
[all-parents changes])))
@@ -172,10 +172,10 @@
new-props (- min-props
(+ (count props)
(if add-name? 1 0)))
props (ctv/add-new-props props (repeat new-props ""))]
props (ctv/add-new-properties props (repeat new-props ""))]
(if add-name?
(ctv/add-new-prop props (:name component))
(ctv/add-new-property props (:name component))
props)))
(defn- create-new-properties-from-non-variant
@@ -216,6 +216,41 @@
:else
(get-instance-root objects (get objects (:parent-id shape)))))
(defn get-all-instance-roots
"Given a list of shape ids and an objects tree, returns a set with the ids of
all instance roots that are at, above or below any of the shapes identified by
the given list. An instance root is a shape that has :component-root set to
true (checked by ctk/instance-root?). There is at most one instance root in
any subtree rooted at an instance root, so the downward search stops at the
first instance root found in each branch. Uses a visited set to avoid
reprocessing the same shapes."
[objects shape-ids]
(let [visited (atom #{})
result (atom #{})]
(letfn [(search-up [shape-id]
(when-not (contains? @visited shape-id)
(swap! visited conj shape-id)
(let [shape (get objects shape-id)]
(when-not (nil? shape)
(if (ctk/instance-root? shape)
(swap! result conj (:id shape))
(when-not (cfh/root? shape)
(when-let [parent-id (:parent-id shape)]
(search-up parent-id))))))))
(search-down [shape-id]
(when-not (contains? @visited shape-id)
(swap! visited conj shape-id)
(let [shape (get objects shape-id)]
(when-not (nil? shape)
(if (ctk/instance-root? shape)
(swap! result conj (:id shape))
(doseq [child-id (:shapes shape)]
(search-down child-id)))))))]
(doseq [shape-id shape-ids]
(search-up shape-id)
(search-down shape-id))
@result)))
(defn find-component-main
"If the shape is a component main instance or is inside one, return that instance.
Uses an iterative loop with cycle detection to prevent stack overflow on circular
+217 -88
View File
@@ -27,6 +27,9 @@
[:variant-id {:optional true} ::sm/uuid]
[:variant-properties {:optional true} [:vector schema:variant-property]]])
(def valid-variant-component?
(sm/check-fn schema:variant-component))
(def schema:variant-shape
"The root shape of the main instance of a variant component"
[:map
@@ -34,14 +37,17 @@
[:variant-name {:optional true} :string]
[:variant-error {:optional true} :string]])
(def valid-variant-shape?
(sm/check-fn schema:variant-shape))
(def schema:variant-container
"Is a board that contains all variant components of a variant set,
for grouping them visually in the workspace"
[:map
[:is-variant-container {:optional true} :boolean]])
(def valid-variant-component?
(sm/check-fn schema:variant-component))
(def valid-variant-container?
(sm/check-fn schema:variant-container))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@@ -51,7 +57,12 @@
(def value-prefix "Value ")
(defn properties-to-name
"Transform the properties into a name, with the values separated by comma"
"Transform the properties into a name, with the values separated by comma.
Example:
[{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}] -> 'Button, Primary'"
[properties]
(->> properties
(map :value)
@@ -59,7 +70,12 @@
(str/join ", ")))
(defn next-property-number
"Returns the next property number, to avoid duplicates on the property names"
"Returns the next property number, to avoid duplicates on the property names.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 3' :value 'y'}] -> 4"
[properties]
(let [numbers (keep
#(some->> (:name %) (re-find property-regex) second d/parse-integer)
@@ -69,37 +85,61 @@
0)]
(inc (max max-num (count properties)))))
(defn add-new-prop
"Adds a new property with generated name and provided value to the existing props list."
[props value]
(conj props {:name (str property-prefix (next-property-number props))
:value value}))
(defn add-new-property
"Adds a new property with generated name and provided value to the existing properties list.
(defn add-new-props
"Adds new properties with generated names and provided values to the existing props list."
[props values]
(let [next-prop-num (next-property-number props)
Example:
[{:name 'Property 1' :value 'x'}] 'y' -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]"
[properties value]
(conj properties {:name (str property-prefix (next-property-number properties))
:value value}))
(defn add-new-properties
"Adds new properties with generated names and provided values to the existing properties list.
Example:
[{:name 'Property 1' :value 'x'}] ['a' 'b'] -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'a'}
{:name 'Property 3' :value 'b'}]"
[properties values]
(let [next-prop-num (next-property-number properties)
xf (map-indexed (fn [i v]
{:name (str property-prefix (+ next-prop-num i))
:value v}))]
(into props xf values)))
(into properties xf values)))
(defn path-to-properties
"From a list of properties and a name with path, assign each token of the
path as value of a different property"
path as value of a different property. It can add blank properties if
necessary, until the min-properties number is reached.
Example with min-properties=4:
'Button / Primary / Hover' -> [{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}
{:name 'Property 3' :value 'Hover'}
{:name 'Property 4' :value ''}]"
([path properties]
(path-to-properties path properties 0))
([path properties min-props]
([path properties min-properties]
(let [cpath (cpn/split-path path)
total-props (max (count cpath) min-props)
total-properties (max (count cpath) min-properties)
assigned (mapv #(assoc % :value (nth cpath %2 "")) properties (range))
;; Add empty strings to the end of cpath to reach the minimum number of properties
cpath (take total-props (concat cpath (repeat "")))
cpath (take total-properties (concat cpath (repeat "")))
remaining (drop (count properties) cpath)]
(add-new-props assigned remaining))))
(add-new-properties assigned remaining))))
(defn properties-map->formula
"Transforms a map of properties to a formula of properties omitting the empty ones"
"Transforms a map of properties to a formula of properties omitting the empty ones.
Example:
[{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}] -> 'Property 1=Button, Property 2=Primary'"
[properties]
(->> properties
(keep (fn [{:keys [name value]}]
@@ -108,7 +148,12 @@
(str/join ", ")))
(defn properties-formula->map
"Transforms a formula of properties to a map of properties"
"Transforms a formula of properties to a map of properties.
Example:
'Property 1=Button, Property 2=Primary' -> [{:name 'Property 1' :value 'Button'}
{:name 'Property 2' :value 'Primary'}]"
[s]
(->> (str/split s ",")
(mapv #(str/split % "=" 2))
@@ -118,7 +163,12 @@
:value (str/trim v)}))))
(defn valid-properties-formula?
"Checks if a formula is valid"
"Checks if a formula is valid.
Example:
'Property 1=Button, Property 2=Primary' -> true
'Property 1=Button, Property 2' -> false"
[s]
(->> (str/split s ",")
(mapv #(str/split % "=" 2))
@@ -128,22 +178,41 @@
(< (count (second %)) property-max-length)))))
(defn find-properties-to-remove
"Compares two property maps to find which properties should be removed"
[prev-props upd-props]
(let [upd-names (set (map :name upd-props))]
(filterv #(not (contains? upd-names (:name %))) prev-props)))
"Compares two property maps to find which properties should be removed.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]
[{:name 'Property 1' :value 'x'}] -> [{:name 'Property 2' :value 'y'}]"
[prev-properties upd-properties]
(let [upd-names (set (map :name upd-properties))]
(filterv #(not (contains? upd-names (:name %))) prev-properties)))
(defn find-properties-to-update
"Compares two property maps to find which properties should be updated"
[prev-props upd-props]
"Compares two property maps to find which properties should be updated.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]
[{:name 'Property 1' :value 'new-x'}
{:name 'Property 2' :value 'y'}] -> [{:name 'Property 1' :value 'new-x'}]"
[prev-properties upd-properties]
(filterv #(some (fn [prop] (and (= (:name %) (:name prop))
(not= (:value %) (:value prop)))) prev-props) upd-props))
(not= (:value %) (:value prop)))) prev-properties) upd-properties))
(defn find-properties-to-add
"Compares two property maps to find which properties should be added"
[prev-props upd-props]
(let [prev-names (set (map :name prev-props))]
(filterv #(not (contains? prev-names (:name %))) upd-props)))
"Compares two property maps to find which properties should be added.
Example:
[{:name 'Property 1' :value 'x'}]
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}] -> [{:name 'Property 2' :value 'y'}]"
[prev-properties upd-properties]
(let [prev-names (set (map :name prev-properties))]
(filterv #(not (contains? prev-names (:name %))) upd-properties)))
(defn- split-base-name-and-number
"Extract the number in parentheses from an item, if present, and return both the base name and the number"
@@ -165,7 +234,12 @@
(defn update-number-in-repeated-item
"Add, keep or update a number in parentheses for a given item, if necessary, depending on the items
already present in a list, to avoid repetitions"
already present in a list, to avoid repetitions.
Example:
['Property'] 'Property' -> 'Property (1)'
['Property' 'Property (1)'] 'Property' -> 'Property (2)'"
[items item]
(let [names (group-numbers-by-base-name items)
[base num] (split-base-name-and-number item)
@@ -176,24 +250,40 @@
(str base (when (pos? n) (str " (" n ")")))))))
(defn update-number-in-repeated-prop-names
"Add, keep or update a number for each prop name depending on the previous ones"
[props]
(->> props
"Add, keep or update a number for each prop name depending on the previous ones.
Example:
[{:name 'Property' :value 'x'}
{:name 'Property' :value 'y'}] -> [{:name 'Property' :value 'x'}
{:name 'Property (1)' :value 'y'}]"
[properties]
(->> properties
(reduce (fn [acc prop]
(conj acc {:name (update-number-in-repeated-item (mapv :name acc) (:name prop))
:value (:value prop)}))
[])))
(defn find-index-for-property-name
"Finds the index of a name in a property map"
[props name]
"Finds the index of a name in a property map.
Example:
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}] 'Property 2' -> 1"
[properties name]
(some (fn [[idx prop]]
(when (= (:name prop) name)
idx))
(map-indexed vector props)))
(map-indexed vector properties)))
(defn remove-prefix
"Removes the given prefix (with or without a trailing ' / ') from the beginning of the name"
"Removes the given prefix (with or without a trailing ' / ') from the beginning of the name.
Example:
'Button / Primary' 'Button' -> 'Primary'
'Button / Primary' 'Other' -> 'Button / Primary'"
[name prefix]
(let [long-name (str prefix " / ")]
(cond
@@ -210,22 +300,22 @@
(map :name))
(defn- matching-indices
[props1 props2]
(let [names-in-p2 (into #{} xf:map-name props2)
[properties1 properties2]
(let [names-in-p2 (into #{} xf:map-name properties2)
xform (comp
(map-indexed (fn [index {:keys [name]}]
(when (contains? names-in-p2 name)
index)))
(filter some?))]
(into #{} xform props1)))
(into #{} xform properties1)))
(defn- find-index-by-name
"Returns the index of the first item in props with the given name, or nil if not found."
[name props]
"Returns the index of the first item in properties with the given name, or nil if not found."
[name properties]
(some (fn [[idx item]]
(when (= (:name item) name)
idx))
(map-indexed vector props)))
(map-indexed vector properties)))
(defn- next-valid-position
"Returns the first non-negative integer not present in the used-pos set."
@@ -236,42 +326,60 @@
p)))
(defn- find-position
"Returns the index of the property with the given name in `props`,
"Returns the index of the property with the given name in `properties`,
or the next available index not in `used-pos` if not found."
[name props used-pos]
(or (find-index-by-name name props)
[name properties used-pos]
(or (find-index-by-name name properties)
(next-valid-position used-pos)))
(defn merge-properties
"Merges props2 into props1 with the following rules:
- For each property p2 in props2:
"Merges properties2 into properties1 with the following rules:
- For each property p2 in properties2:
- Skip it if its value is empty.
- If props1 contains a property with the same name, update its value with that of p2.
- Otherwise, assign p2's value to the first unused property in props1. A property is considered used if:
- Its name exists in both props1 and props2, or
- If properties1 contains a property with the same name, update its value with that of p2.
- Otherwise, assign p2's value to the first unused property in properties1. A property is considered used if:
- Its name exists in both properties1 and properties2, or
- Its value has already been updated during the merge.
- If no unused properties are available in props1, append a new property with a default name and p2's value."
[props1 props2]
(let [props2 (remove #(str/empty? (:value %)) props2)]
- If no unused properties are available in properties1, append a new property with a default name and p2's value.
Example:
[{:name 'Property 1' :value 'a'}
{:name 'Property 2' :value 'b'}]
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}
{:name 'Property 3' :value 'z'}] -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}
{:name 'Property 3' :value 'z'}]"
[properties1 properties2]
(let [properties2 (remove #(str/empty? (:value %)) properties2)]
(-> (reduce
(fn [{:keys [props used-pos]} prop]
(let [pos (find-position (:name prop) props used-pos)
(fn [{:keys [properties used-pos]} prop]
(let [pos (find-position (:name prop) properties used-pos)
used-pos (conj used-pos pos)]
(if (< pos (count props))
{:props (assoc-in (vec props) [pos :value] (:value prop)) :used-pos used-pos}
{:props (add-new-prop props (:value prop)) :used-pos used-pos})))
{:props (vec props1) :used-pos (matching-indices props1 props2)}
props2)
:props)))
(if (< pos (count properties))
{:properties (assoc-in (vec properties) [pos :value] (:value prop)) :used-pos used-pos}
{:properties (add-new-property properties (:value prop)) :used-pos used-pos})))
{:properties (vec properties1) :used-pos (matching-indices properties1 properties2)}
properties2)
:properties)))
(defn compare-properties
"Compares vectors of properties keeping the value if it is the same for all
or setting a custom value where their values do not coincide"
([props-list]
(compare-properties props-list nil))
or setting a custom value where their values do not coincide.
([props-list distinct-mark]
(let [grouped (group-by :name (apply concat props-list))
Example:
[[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'y'}]
[{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value 'z'}]] -> [{:name 'Property 1' :value 'x'}
{:name 'Property 2' :value nil}]"
([properties-list]
(compare-properties properties-list nil))
([properties-list distinct-mark]
(let [grouped (group-by :name (apply concat properties-list))
check-values (fn [values]
(let [vals (map :value values)]
(if (apply = vals)
@@ -281,33 +389,35 @@
{:name name :value (check-values values)})
grouped))))
(defn same-variant?
"Determines if all elements belong to the same variant"
[components]
(let [variant-ids (distinct (map :variant-id components))
not-blank? (complement str/blank?)]
(and
(= 1 (count variant-ids))
(not-blank? (first variant-ids)))))
(defn properties-distance
"Computes a weighted distance between two property lists `properties1` and `properties2`.
Latter properties weight less that previous ones.
(defn distance
"Computes a weighted distance between two property lists `props1` and `props2`.
Latter properties weight less that previous ones"
[props1 props2]
(let [total-num-props (count props1)
Example:
[{:name 'type' :value 'primary'}
{:name 'status' :value 'default'}]
[{:name 'type' :value 'primary'}
{:name 'status' :value 'hover'}] -> 1.0"
[properties1 properties2]
(let [total-num-properties (count properties1)
xform (map-indexed
(fn [idx [p1 p2]]
(if (not= p1 p2)
(math/pow 2 (- total-num-props idx))
(math/pow 2 (- total-num-properties idx))
0)))]
(transduce
xform
+
(map vector props1 props2))))
(map vector properties1 properties2))))
(defn variant-name-to-name
"Transforms a variant-name (its properties values) into a standard name:
the real name of the shape joined by the properties values separated by '/'"
the real name of the shape joined by the properties values separated by '/'.
Example:
{:name 'Button' :variant-name 'Primary, Hover'} -> 'Button / Primary / Hover'"
[variant]
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
@@ -318,7 +428,12 @@
(defn find-boolean-pair
"Given a vector, return a map that contains the boolean equivalency if the values match
with any of the boolean pairs. Returns nil if none match."
with any of the boolean pairs. Returns nil if none match.
Example:
['on' 'off'] -> {'on' true 'off' false}
['foo' 'bar'] -> nil"
[[a b :as v]]
(let [a' (-> a str/trim str/lower)
b' (-> b str/trim str/lower)]
@@ -330,3 +445,17 @@
(= a' f)) {b true a false}
:else nil))
boolean-pairs))))
(defn same-variant?
"Determines if all elements belong to the same variant.
Example:
[{:variant-id 'abc'} {:variant-id 'abc'}] -> true
[{:variant-id 'abc'} {:variant-id 'def'}] -> false"
[components]
(let [variant-ids (distinct (map :variant-id components))
not-blank? (complement str/blank?)]
(and
(= 1 (count variant-ids))
(not-blank? (first variant-ids)))))
+2
View File
@@ -87,6 +87,7 @@
[common-tests.types.token-test]
[common-tests.types.tokens-lib-test]
[common-tests.types.tokens-status-test]
[common-tests.types.variant-test]
[common-tests.undo-stack-test]
[common-tests.uuid-test]))
@@ -142,6 +143,7 @@
'common-tests.logic.token-test
'common-tests.logic.variants-switch-test
'common-tests.math-test
'common-tests.types.variant-test
'common-tests.media-test
'common-tests.path-names-test
'common-tests.record-test
+228 -10
View File
@@ -9,8 +9,220 @@
[app.common.types.variant :as ctv]
[clojure.test :as t]))
(t/deftest properties-to-name-test
(t/is (= "" (ctv/properties-to-name [])))
(t/is (= "" (ctv/properties-to-name nil)))
(t/is (= "Button, Primary" (ctv/properties-to-name [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}])))
(t/is (= "Button" (ctv/properties-to-name [{:name "Property 1" :value "Button"}
{:name "Property 2" :value ""}]))))
(t/deftest variant-distance01
(t/deftest next-property-number-test
(t/is (= 1 (ctv/next-property-number [])))
(t/is (= 1 (ctv/next-property-number nil)))
(t/is (= 2 (ctv/next-property-number [{:name "Property 1" :value "x"}])))
(t/is (= 4 (ctv/next-property-number [{:name "Property 3" :value "x"}])))
(t/is (= 3 (ctv/next-property-number [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]))))
(t/deftest add-new-property-test
(t/is (= [{:name "Property 1" :value "x"}]
(ctv/add-new-property [] "x")))
(t/is (= [{:name "Property 1" :value "x"}]
(ctv/add-new-property nil "x")))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/add-new-property [{:name "Property 1" :value "x"}] "y"))))
(t/deftest add-new-properties-test
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "b"}]
(ctv/add-new-properties [] ["a" "b"])))
(t/is (= '({:name "Property 2" :value "b"} {:name "Property 1" :value "a"})
(ctv/add-new-properties nil ["a" "b"])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "a"} {:name "Property 3" :value "b"}]
(ctv/add-new-properties [{:name "Property 1" :value "x"}] ["a" "b"]))))
(t/deftest path-to-properties-test
(t/is (= [] (ctv/path-to-properties "" [])))
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "b"}]
(ctv/path-to-properties "a / b" nil)))
(t/is (= [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}
{:name "Property 3" :value "Hover"}]
(ctv/path-to-properties "Button / Primary / Hover" [])))
(t/is (= [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}
{:name "Property 3" :value "Hover"}
{:name "Property 4" :value ""}]
(ctv/path-to-properties "Button / Primary / Hover" [] 4)))
(t/is (= [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}]
(ctv/path-to-properties "Button / Primary" [{:name "Property 1" :value "old"}
{:name "Property 2" :value "old2"}]))))
(t/deftest properties-map->formula-test
(t/is (= "" (ctv/properties-map->formula [])))
(t/is (= "" (ctv/properties-map->formula nil)))
(t/is (= "Property 1=Button, Property 2=Primary"
(ctv/properties-map->formula [{:name "Property 1" :value "Button"}
{:name "Property 2" :value "Primary"}])))
(t/is (= "Property 1=Button"
(ctv/properties-map->formula [{:name "Property 1" :value "Button"}
{:name "Property 2" :value ""}]))))
(t/deftest properties-formula->map-test
(t/is (= [] (ctv/properties-formula->map "")))
(t/is (= [] (ctv/properties-formula->map nil)))
(t/is (= [{:name "Property 1" :value "Button"} {:name "Property 2" :value "Primary"}]
(ctv/properties-formula->map "Property 1=Button, Property 2=Primary")))
(t/is (= [{:name "Property 1" :value "Button"}]
(ctv/properties-formula->map "Property 1=Button, Property 2="))))
(t/deftest valid-properties-formula?-test
(t/is (= true (ctv/valid-properties-formula? "Property 1=Button, Property 2=Primary")))
(t/is (= false (ctv/valid-properties-formula? "")))
(t/is (= true (ctv/valid-properties-formula? nil)))
(t/is (= false (ctv/valid-properties-formula? "Property 1=Button, Property 2"))))
(t/deftest find-properties-to-remove-test
(t/is (= [] (ctv/find-properties-to-remove [] [])))
(t/is (= [] (ctv/find-properties-to-remove nil nil)))
(t/is (= [{:name "Property 3" :value "z"}]
(ctv/find-properties-to-remove [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}
{:name "Property 3" :value "z"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/find-properties-to-remove [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 3" :value "z"}]))))
(t/deftest find-properties-to-update-test
(t/is (= [] (ctv/find-properties-to-update [] [])))
(t/is (= [] (ctv/find-properties-to-update nil nil)))
(t/is (= [{:name "Property 1" :value "new-x"}]
(ctv/find-properties-to-update [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "new-x"}
{:name "Property 2" :value "y"}])))
(t/is (= [{:name "Property 1" :value "new-x"} {:name "Property 2" :value "new-y"}]
(ctv/find-properties-to-update [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "new-x"}
{:name "Property 2" :value "new-y"}]))))
(t/deftest find-properties-to-add-test
(t/is (= [] (ctv/find-properties-to-add [] [])))
(t/is (= [] (ctv/find-properties-to-add nil nil)))
(t/is (= [{:name "Property 3" :value "z"}]
(ctv/find-properties-to-add [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}
{:name "Property 3" :value "z"}])))
(t/is (= [{:name "Property 2" :value "y"}]
(ctv/find-properties-to-add [{:name "Property 1" :value "x"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]))))
(t/deftest update-number-in-repeated-item-test
(t/is (= "Property" (ctv/update-number-in-repeated-item [] "Property")))
(t/is (= "Property" (ctv/update-number-in-repeated-item nil "Property")))
(t/is (= "Property (1)" (ctv/update-number-in-repeated-item ["Property"] "Property")))
(t/is (= "Property (2)" (ctv/update-number-in-repeated-item ["Property" "Property (1)"] "Property")))
(t/is (= "Property" (ctv/update-number-in-repeated-item ["Other"] "Property"))))
(t/deftest update-number-in-repeated-prop-names-test
(t/is (= [] (ctv/update-number-in-repeated-prop-names [])))
(t/is (= [] (ctv/update-number-in-repeated-prop-names nil)))
(t/is (= [{:name "Property" :value "x"}]
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}])))
(t/is (= [{:name "Property" :value "x"} {:name "Property (1)" :value "y"}]
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}
{:name "Property" :value "y"}])))
(t/is (= [{:name "Property" :value "x"} {:name "Property (1)" :value "y"} {:name "Property (2)" :value "z"}]
(ctv/update-number-in-repeated-prop-names [{:name "Property" :value "x"}
{:name "Property" :value "y"}
{:name "Property" :value "z"}]))))
(t/deftest find-index-for-property-name-test
(t/is (= nil (ctv/find-index-for-property-name [] "Property 1")))
(t/is (= nil (ctv/find-index-for-property-name nil "Property 1")))
(t/is (= 0 (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}] "Property 1")))
(t/is (= 1 (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}] "Property 2")))
(t/is (= nil (ctv/find-index-for-property-name [{:name "Property 1" :value "x"}] "Property 3"))))
(t/deftest remove-prefix-test
(t/is (= "name" (ctv/remove-prefix "name" "")))
(t/is (= "name" (ctv/remove-prefix "name" nil)))
(t/is (= "Primary" (ctv/remove-prefix "Button / Primary" "Button")))
(t/is (= "Primary" (ctv/remove-prefix "Button / Primary" "Button / ")))
(t/is (= "Button / Primary" (ctv/remove-prefix "Button / Primary" "Other"))))
(t/deftest merge-properties-test
(t/is (= [] (ctv/merge-properties [] [])))
(t/is (= [] (ctv/merge-properties nil nil)))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/merge-properties [{:name "Property 1" :value "a"}
{:name "Property 2" :value "b"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"} {:name "Property 3" :value "z"}]
(ctv/merge-properties [{:name "Property 1" :value "a"}
{:name "Property 2" :value "b"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}
{:name "Property 3" :value "z"}])))
(t/is (= [{:name "Property 1" :value "a"} {:name "Property 2" :value "y"}]
(ctv/merge-properties [{:name "Property 1" :value "a"}
{:name "Property 2" :value "b"}]
[{:name "Property 2" :value "y"}]))))
(t/deftest compare-properties-test
(t/is (= [] (ctv/compare-properties [])))
(t/is (= [] (ctv/compare-properties nil)))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "y"}]
(ctv/compare-properties [[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value nil}]
(ctv/compare-properties [[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "z"}]])))
(t/is (= [{:name "Property 1" :value "x"} {:name "Property 2" :value "*"}]
(ctv/compare-properties [[{:name "Property 1" :value "x"}
{:name "Property 2" :value "y"}]
[{:name "Property 1" :value "x"}
{:name "Property 2" :value "z"}]]
"*"))))
(t/deftest variant-name-to-name-test
(t/is (= "Button / Primary / Hover" (ctv/variant-name-to-name {:name "Button" :variant-name "Primary, Hover"})))
(t/is (= "Button" (ctv/variant-name-to-name {:name "Button" :variant-name ""})))
(t/is (= "Button" (ctv/variant-name-to-name {:name "Button" :variant-name nil})))
(t/is (= "" (ctv/variant-name-to-name {:name "" :variant-name ""})))
(t/is (= nil (ctv/variant-name-to-name {:name nil :variant-name nil}))))
(t/deftest find-boolean-pair-test
(t/is (= {"on" true "off" false} (ctv/find-boolean-pair ["on" "off"])))
(t/is (= {"yes" true "no" false} (ctv/find-boolean-pair ["yes" "no"])))
(t/is (= {"true" true "false" false} (ctv/find-boolean-pair ["true" "false"])))
(t/is (= {"on" true "off" false} (ctv/find-boolean-pair ["off" "on"])))
(t/is (= {"ON" true "OFF" false} (ctv/find-boolean-pair ["ON" "OFF"])))
(t/is (= nil (ctv/find-boolean-pair ["foo" "bar"])))
(t/is (= nil (ctv/find-boolean-pair nil)))
(t/is (= nil (ctv/find-boolean-pair ["on"]))))
(t/deftest same-variant?-test
(t/is (= false (ctv/same-variant? [])))
(t/is (= false (ctv/same-variant? nil)))
(t/is (= true (ctv/same-variant? [{:variant-id "abc"}])))
(t/is (= true (ctv/same-variant? [{:variant-id "abc"} {:variant-id "abc"}])))
(t/is (= false (ctv/same-variant? [{:variant-id "abc"} {:variant-id "def"}])))
(t/is (= false (ctv/same-variant? [{:variant-id ""} {:variant-id ""}]))))
(t/deftest properties-distance01
;;c1: primary, default, rounded, blue, dark
;;c2: primary, hover, squared, blue, dark
;;c3: primary, default, squared, blue, light
@@ -35,12 +247,12 @@
{:name "borders" :value "rounded"}
{:name "color" :value "blue"}
{:name "theme" :value "light"}]
dist2 (ctv/distance target props2)
dist3 (ctv/distance target props3)]
dist2 (ctv/properties-distance target props2)
dist3 (ctv/properties-distance target props3)]
(t/is (< dist3 dist2))))
(t/deftest variant-distance02
(t/deftest properties-distance02
;;c1: primary, default, rounded, blue, dark
;;c2: primary, hover, squared, red, dark
;;c3: secondary, hover, rounded, blue, dark
@@ -65,11 +277,11 @@
{:name "borders" :value "rounded"}
{:name "color" :value "blue"}
{:name "theme" :value "dark"}]
dist2 (ctv/distance target props2)
dist3 (ctv/distance target props3)]
dist2 (ctv/properties-distance target props2)
dist3 (ctv/properties-distance target props3)]
(t/is (< dist2 dist3))))
(t/deftest variant-distance03
(t/deftest properties-distance03
;;c1: primary, default, rounded, blue, dark
;;c2: secondary, default, rounded, blue, light
;;c3: secondary, hover, squared, blue, dark
@@ -101,12 +313,18 @@
{:name "borders" :value "rounded"}
{:name "color" :value "blue"}
{:name "theme" :value "dark"}]
dist2 (ctv/distance target props2)
dist3 (ctv/distance target props3)
dist4 (ctv/distance target props4)]
dist2 (ctv/properties-distance target props2)
dist3 (ctv/properties-distance target props3)
dist4 (ctv/properties-distance target props4)]
(t/is (< dist2 dist4))
(t/is (< dist4 dist3))))
(t/deftest properties-distance04
(t/is (= 0 (ctv/properties-distance [] [])))
(t/is (= 0 (ctv/properties-distance nil nil)))
(t/is (= 0 (ctv/properties-distance [{:name "a" :value "x"}] [{:name "a" :value "x"}])))
(t/is (= 2.0 (ctv/properties-distance [{:name "a" :value "x"} {:name "b" :value "y"}] [{:name "a" :value "x"} {:name "b" :value "z"}]))))
-17
View File
@@ -25,7 +25,6 @@
[app.common.uuid :as uuid]
;; Required for side effects: binds the generated enums.
[app.wasm.enums]
[cuerdas.core :as str]
[promesa.core :as p]
[shadow.esm :refer [dynamic-import]]))
@@ -191,22 +190,6 @@
(aget buf 0) (aget buf 1) (aget buf 2) (aget buf 3)
false)))))
(defn store-image-url!
"Registers the public URL an image was loaded from. The SVG export emits
linked `<image href>` from these, and falls back to Skia base64 when missing,
so this should run for every media id the scene references (including
already-cached images).
Does NOT call `mem/free`, for the same reason as `store-font-url!`."
[image-id url]
(when (and (some? url) (not (str/blank? url)))
(let [bytes (js/Buffer.from url "utf-8")
ptr (mem/alloc (.-byteLength bytes))
quart (uuid/get-u32 image-id)]
(mem/write-buffer ptr (mem/get-heap-u8) bytes)
(h/call wasm/internal-module "_store_image_url"
(aget quart 0) (aget quart 1) (aget quart 2) (aget quart 3)))))
(defn store-image!
"Uploads one image's *encoded* bytes (PNG/JPEG — Skia decodes, no WebGL) into
the WASM image store via `_store_image`. Buffer layout matches the Rust reader:
+1 -6
View File
@@ -369,18 +369,13 @@
"Fetches and stores every image the scene references (shape, stroke and
text-span fills, enumerated by `app.common.types.shape.images`). Unlike fonts,
the image store is not reset per request, so already-held images are skipped
and repeated exports of a file reuse them.
Always registers a public media URL for each id so SVG export can emit linked
`<image href>` even when the encoded bytes were already cached."
and repeated exports of a file reuse them."
[scene params]
(let [all-ids (images/scene-image-ids scene)
new-ids (remove wasm/image-cached? all-ids)]
(l/dbg :hint "wasm render: provisioning images"
:total (count all-ids)
:cached (- (count all-ids) (count new-ids)))
(doseq [image-id all-ids]
(wasm/store-image-url! image-id (public-uri (str "assets/by-file-media-id/" image-id))))
(->> new-ids
(map (fn [image-id]
(->> (fetch-file-media-bytes image-id params)
+8 -6
View File
@@ -103,12 +103,14 @@
(wasm.api/process-objects shapes)
(wasm.api/request-render "sync-wasm-structural-changes"))))))
(defn- apply-changes-localy
(defn- apply-changes-locally
[{:keys [file-id redo-changes ignore-wasm?] :as commit} pending]
(ptk/reify ::apply-changes-localy
(ptk/reify ::apply-changes-locally
ptk/UpdateEvent
(update [_ state]
(let [undo-changes
(let [libraries (dsh/lookup-libraries state)
undo-changes
(if pending
(->> pending
(map :undo-changes)
@@ -126,8 +128,8 @@
apply-changes
(fn [fdata]
(let [fdata (cpc/process-changes fdata undo-changes false)
fdata (cpc/process-changes fdata redo-changes false)
(let [fdata (cpc/process-changes fdata undo-changes false libraries)
fdata (cpc/process-changes fdata redo-changes false libraries)
pids (into #{} xf:map-page-id redo-changes)]
(reduce #(ctst/update-object-indices %1 %2) fdata pids)))]
@@ -200,7 +202,7 @@
(let [pending (when-not local?
(get-pending-commits state))]
(rx/concat
(rx/of (apply-changes-localy commit pending))
(rx/of (apply-changes-locally commit pending))
(if pending
(rx/concat
(->> (rx/from (reverse pending))
@@ -193,7 +193,8 @@
layout-initializer (get-layout-initializer type from-frame? calculate-params?)]
(rx/of (dwu/start-undo-transaction undo-id)
(dwsh/update-shapes [id] layout-initializer {:with-objects? true})
(dwsh/update-shapes [id] layout-initializer {:with-objects? true
:extra-context (str "create-layout-from-id: " id)})
(dwsh/update-shapes (dm/get-prop parent :shapes) #(dissoc % :constraints-h :constraints-v))
(ptk/data-event :layout/update {:ids [id]})
(dwu/commit-undo-transaction undo-id))))))
@@ -189,7 +189,7 @@
{:as props
:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr translation?
skip-component-sync?]
skip-component-sync? extra-context]
:or {reg-objects? false
save-undo? true
stack-undo? false
@@ -222,7 +222,8 @@
:ignore-tree ignore-tree
:ignore-touched ignore-touched
:with-objects? with-objects?
:translation? translation?})
:translation? translation?
:extra-context extra-context})
(cond-> undo-group
(pcb/set-undo-group undo-group))
(pcb/set-translation? translation?)
@@ -764,7 +764,7 @@
(remove #(= (:id %) component-id))
(filter #(= (dm/get-in % [:variant-properties pos :value]) val))
(reverse))
nearest-comp (apply min-key #(ctv/distance target-props (:variant-properties %)) valid-comps)
nearest-comp (apply min-key #(ctv/properties-distance target-props (:variant-properties %)) valid-comps)
shape-parents (cfh/get-parents-with-self current-page-objects (:parent-id shape))
nearest-comp-children (cfh/get-children-with-self component-page-objects (:main-instance-id nearest-comp))
comps-nesting-loop? (seq? (cfh/components-nesting-loop? nearest-comp-children shape-parents))
-24
View File
@@ -880,25 +880,6 @@
(h/call wasm/internal-module "_store_image")
true)))))
(defn- store-image-url!
"Registers the public URL an image was loaded from so SVG export can emit a
linked `<image href>` instead of a Skia base64 embed."
[image-id url]
(when (and (wasm/live?) (some? url) (not (str/blank? url)))
(let [buffer (uuid/get-u32 image-id)
encoder (js/TextEncoder.)
encoded (.encode encoder url)
size (.-byteLength encoded)
offset (mem/alloc size)
heap (mem/get-heap-u8)]
(.set heap encoded offset)
(h/call wasm/internal-module "_store_image_url"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))
true)))
(defn- store-image-texture
"Creates a WebGL texture from a decoded image and passes the texture ID to
WASM. This avoids decoding the image twice (once in browser, once in WASM)."
@@ -941,7 +922,6 @@
so Skia rasterizes them."
[shape-id image-id thumbnail?]
(let [url (cf/resolve-file-media {:id image-id} thumbnail?)]
(store-image-url! image-id url)
{:key url
:thumbnail? thumbnail?
:callback
@@ -979,8 +959,6 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
;; Always register the URL (SVG export needs it even when bytes are cached).
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?)))))
@@ -1015,7 +993,6 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(store-image-url! id (cf/resolve-file-media {:id id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
(types.fills/get-image-ids fills))))))
@@ -1044,7 +1021,6 @@
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(store-image-url! image-id (cf/resolve-file-media {:id image-id} thumbnail?))
(when (zero? cached-image?)
(fetch-image shape-id image-id thumbnail?))))
image-ids))))
-1
View File
@@ -10,7 +10,6 @@
- **plugins-runtime**: `Library.createComponent()` now rejects invalid input (an empty shape list, or a shape inside a component copy) with a validation error instead of returning a component proxy pointing at nothing.
- **plugins-runtime**: Setting an individual padding/margin side (`leftPadding`, `topMargin`, …) now re-derives the padding/margin type, switching to `multiple` when the four sides stop being symmetric (so the value is actually painted) and back to `simple` once top/bottom and left/right are mirrored again.
- **plugins-runtime**: Removed the premature deep-hardening of the host plugin context, which froze shared host functions (including `Function.prototype`) before SES override taming, causing `TypeError: Cannot assign to read only property 'toString'` on later host-side function extension. Related to #11001.
## 1.5.0 (2026-07-08)
@@ -1,82 +0,0 @@
import { describe, it, vi, expect, beforeEach } from 'vitest';
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
import { createPlugin } from './create-plugin';
import { ses } from './ses.js';
import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
vi.mock('./create-plugin', () => ({
createPlugin: vi.fn(),
}));
// NOTE: `./ses.js` is intentionally NOT mocked here: the test spies on the
// real `ses.harden` to assert that `loadPlugin` never hardens the host
// context.
describe('loadPlugin host context boundary (regression for #11001)', () => {
let manifest: Manifest;
beforeEach(() => {
manifest = {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: '',
code: '',
permissions: ['content:read'],
};
vi.mocked(createPlugin).mockResolvedValue({
plugin: {
close: vi.fn(),
sendMessage: vi.fn(),
},
} as unknown as Awaited<ReturnType<typeof createPlugin>>);
});
it('does not freeze host-owned functions reachable through the context', async () => {
const hostListener = function hostListener() {
return 'host-value';
};
const nestedHostObject = {
nestedFn() {
return 'nested';
},
};
const hostContext = {
addListener: hostListener,
nested: nestedHostObject,
} as unknown as Context;
setContextBuilder(() => hostContext);
const hardenSpy = vi.spyOn(ses, 'harden');
await loadPlugin(manifest);
// The host context itself must be passed through untouched so the host
// can keep modifying its own runtime objects (e.g. on page navigation).
expect(createPlugin).toHaveBeenCalledWith(
hostContext,
manifest,
expect.any(Function),
undefined,
);
// Host-owned functions must remain extensible: page navigation and
// runtime code may patch/augment them (e.g. assigning `toString` on a
// wrapped listener). A deep `ses.harden(context)` here would freeze
// them and turn such later assignments into
// `TypeError: Cannot assign to read only property 'toString'`.
expect(Object.isFrozen(hostListener)).toBe(false);
expect(Object.isExtensible(hostListener)).toBe(true);
expect(Object.isFrozen(nestedHostObject)).toBe(false);
expect(() => {
hostListener.toString = () => 'patched-by-host';
}).not.toThrow();
expect(hardenSpy).not.toHaveBeenCalled();
expect(getPlugins()).toHaveLength(1);
hardenSpy.mockRestore();
});
});
@@ -1,169 +0,0 @@
import { describe, it, vi, expect, beforeAll } from 'vitest';
import 'ses';
import { loadPlugin, setContextBuilder, getPlugins } from './load-plugin';
import type { Context } from '@penpot/plugin-types';
import type { Manifest } from './models/manifest.model.js';
// Real initialization-path regression tests for #11001.
//
// NOTE: `./create-plugin`, `./plugin-manager` and
// `./create-sandbox` are intentionally NOT mocked here. This spec exercises
// the real `loadPlugin → createPlugin → createPluginManager → createSandbox`
// path with the real SES implementation, mirroring the production
// initialization order from `plugins-runtime/src/index.ts`:
// repairIntrinsics (module load) → loadPlugin → createSandbox/hardenIntrinsics
//
// `hardenIntrinsics()` is deliberately NOT called up front: the first test
// must run in the production window where only `repairIntrinsics` has run.
// Tests run in declaration order; the later tests build on the locked-down
// state the first real `loadPlugin` leaves behind (via `createSandbox`).
//
// Note on SES isolation: this suite depends on Vitest's default
// file-level isolation. Each test file runs in a separate worker
// process, so SES intrinsics frozen here do not leak into other
// spec files.
const REPAIR_OPTIONS = {
evalTaming: 'unsafeEval',
stackFiltering: 'verbose',
errorTaming: 'unsafe',
consoleTaming: 'unsafe',
errorTrapping: 'none',
unhandledRejectionTrapping: 'none',
};
function makeManifest(
code: string,
permissions: Manifest['permissions'],
): Manifest {
return {
pluginId: 'test-plugin',
name: 'Test Plugin',
host: '',
code,
permissions,
};
}
function makeHostFixture() {
const listenerTypes: string[] = [];
const listeners = new Map<symbol, string>();
// Inline code (empty host + non-URL code) resolves without network, so no
// fetch mock is needed. UI/modal APIs are never touched by the probe code.
const createRectangle = vi.fn(() => ({ type: 'rectangle-marker' }));
const selection: object[] = [{ id: 'shape-1' }];
const context = {
addListener: (type: string, _callback: (...args: unknown[]) => unknown) => {
const id = Symbol(type);
listeners.set(id, type);
listenerTypes.push(type);
return id;
},
removeListener: (id: symbol) => {
listeners.delete(id);
},
theme: 'dark',
createRectangle,
selection,
// Host-only member: present on the raw context but NOT part of the
// public penpot API. Plugin code must never see it (see B-2 below).
__internalSecret: 'host-internal',
} as unknown as Context;
return { context, listenerTypes, createRectangle, selection };
}
function lastCompartmentGlobalThis(): Record<string, unknown> {
const plugins = getPlugins();
const last = plugins[plugins.length - 1] as unknown as {
compartment: { compartment: { globalThis: Record<string, unknown> } };
};
return last.compartment.compartment.globalThis;
}
describe('loadPlugin real initialization path (regression for #11001)', () => {
beforeAll(() => {
// Production module-load step only: repairs intrinsics WITHOUT
// installing override taming, exactly like `index.ts` at import time.
(
globalThis as unknown as { repairIntrinsics(opts: object): void }
).repairIntrinsics({ ...REPAIR_OPTIONS });
});
it('loads through the real path and keeps host function augmentation working', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('penpot.on("finish", function () {});', ['content:read']),
);
// The plugin code really ran inside the sandbox: the manager registers
// `themechange` + `finish`, and the plugin code adds its own `finish`
// listener through the public API.
expect(fixture.listenerTypes).toEqual(['themechange', 'finish', 'finish']);
expect(getPlugins()).toHaveLength(1);
// The user-facing behavior from #11001: host-side augmentation of a
// fresh function (e.g. assigning `toString` during page navigation)
// succeeds after a real plugin load.
const freshWrapper = function freshWrapper() {
return 'navigation-wrapper';
};
expect(() => {
freshWrapper.toString = () => 'patched-by-runtime';
}).not.toThrow();
expect(fixture.listenerTypes.length).toBe(3);
});
it('denies the write API without permission and leaves the host untouched', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await expect(
loadPlugin(makeManifest('penpot.createRectangle();', ['content:read'])),
).rejects.toThrow(/content:write/);
expect(fixture.createRectangle).not.toHaveBeenCalled();
});
it('allows the same write API with permission', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('penpot.createRectangle();', [
'content:read',
'content:write',
]),
);
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
});
it('does not expose raw host-only context members to plugin code', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest('globalThis.__probe = typeof penpot.__internalSecret;', [
'content:read',
]),
);
// The public `penpot` object is a boundary proxy over a curated API, not
// the raw host context, so host-only members are invisible inside.
expect(lastCompartmentGlobalThis()['__probe']).toBe('undefined');
});
it('keeps safeReturn protection on returned values without blocking allowed edits', async () => {
const fixture = makeHostFixture();
setContextBuilder(() => fixture.context);
await loadPlugin(
makeManifest(
'penpot.createRectangle(); ' +
'globalThis.__selectionFrozen = Object.isFrozen(penpot.selection);',
['content:read', 'content:write'],
),
);
expect(fixture.createRectangle).toHaveBeenCalledTimes(1);
expect(lastCompartmentGlobalThis()['__selectionFrozen']).toBe(true);
});
});
@@ -3,6 +3,7 @@ import type { Context } from '@penpot/plugin-types';
import { loadManifest } from './parse-manifest.js';
import { Manifest } from './models/manifest.model.js';
import { createPlugin } from './create-plugin.js';
import { ses } from './ses.js';
let plugins: Awaited<ReturnType<typeof createPlugin>>[] = [];
@@ -53,23 +54,8 @@ export const loadPlugin = async function (
closeAllPlugins();
// The host context is not deeply frozen at this load stage.
//
// The context still contains host-internal function objects and shared
// prototypes that the host may legitimately extend after plugin load
// (for example, by assigning custom properties). Deep-freezing here
// would freeze those prototypes before SES override taming completes,
// preventing later host-side mutations with a "Cannot assign to read
// only property" TypeError.
//
// Responsibility boundary: this function forwards the context to the
// sandbox layer without deep-freezing it. The public API that plugins
// consume is constructed by the API module (`api/index.ts`), and
// `createSandbox`'s proxy handler applies `ses.safeReturn` to values
// crossing into the sandbox. Compartment isolation and intrinsics
// hardening are performed by createSandbox, not here.
const plugin = await createPlugin(
context,
ses.harden(context) as Context,
manifest,
() => {
plugins = plugins.filter((api) => api !== plugin);
-5
View File
@@ -12,7 +12,6 @@
#
# Text snapshots reference `fonts/sourcesanspro-regular.ttf`; this script copies
# the bundled font into `target/svg-preview/fonts/` so the gallery renders text.
# Image-fill snapshots reference `images/test-fill.svg`; same idea for fills.
#
# When a test produced a pending change there will be a `*.snap.new` next to the
# accepted `*.snap`; the gallery then shows "accepted" vs "new" side by side.
@@ -28,14 +27,10 @@ OUT_DIR="$SCRIPT_DIR/target/svg-preview"
OUT="$OUT_DIR/index.html"
FONT_SRC="$SCRIPT_DIR/src/fonts/sourcesanspro-regular.ttf"
FONT_DIR="$OUT_DIR/fonts"
IMAGE_SRC="$SCRIPT_DIR/src/render/svg/fixtures/test-fill.svg"
IMAGE_DIR="$OUT_DIR/images"
mkdir -p "$OUT_DIR"
mkdir -p "$FONT_DIR"
mkdir -p "$IMAGE_DIR"
cp "$FONT_SRC" "$FONT_DIR/"
cp "$IMAGE_SRC" "$IMAGE_DIR/"
# Prints the SVG body of a snapshot file: everything after the second `---`
# line (the YAML front matter insta writes).
-15
View File
@@ -82,9 +82,6 @@ pub struct ImageStore {
tick: Cell<u64>,
/// gpu-only
context: Option<Box<DirectContext>>,
/// Source URL registered when the image was fetched (SVG export references
/// this in linked `<image>` elements).
source_urls: HashMap<Uuid, String>,
}
/// Creates a Skia image from an existing WebGL texture.
@@ -230,7 +227,6 @@ impl ImageStore {
total_bytes: 0,
tick: Cell::new(0),
context: Some(Box::new(context.clone())),
source_urls: HashMap::new(),
}
}
@@ -243,7 +239,6 @@ impl ImageStore {
total_bytes: 0,
tick: Cell::new(0),
context: None,
source_urls: HashMap::new(),
}
}
@@ -481,14 +476,4 @@ impl ImageStore {
None
}
}
pub(crate) fn set_source_url(&mut self, id: Uuid, url: String) {
if !url.is_empty() {
self.source_urls.insert(id, url);
}
}
pub(crate) fn source_url(&self, id: &Uuid) -> Option<&str> {
self.source_urls.get(id).map(String::as_str)
}
}
+1 -23
View File
@@ -16,7 +16,7 @@ use crate::render::vector::draw_shape_geometry;
/// Accumulates the SVG document body while drawing.
pub(crate) struct SvgLayerCanvas {
scale: f32,
pub(super) scale: f32,
page_rect: skia::Rect,
tx: f32,
ty: f32,
@@ -97,28 +97,6 @@ impl SvgLayerCanvas {
self.out.push_str("</g>");
}
/// Appends raw SVG markup to the body (flushes any pending Skia fragment first).
pub(super) fn push_raw(&mut self, markup: &str) {
self.flush();
self.out.push_str(markup);
}
/// CTM for leaf content placed in page space: Scale * Translate * Centered.
pub(super) fn page_shape_matrix_attr(&self, shape: &Shape) -> String {
let mut ctm = skia::Matrix::scale((self.scale, self.scale));
ctm = ctm * skia::Matrix::translate((self.tx, self.ty));
ctm = ctm * shape.centered_transform();
format!(
"matrix({} {} {} {} {} {})",
ctm.scale_x(),
ctm.skew_y(),
ctm.skew_x(),
ctm.scale_y(),
ctm.translate_x(),
ctm.translate_y()
)
}
/// Emits a `<clipPath>` from a shape's geometry (in device/page space).
///
/// A mask can be a group too. Since a group has no geometry of its own, we
+2 -147
View File
@@ -5,7 +5,7 @@ use skia_safe as skia;
use crate::globals::TestRenderResourcesGuard;
use crate::render::{FontStore, RenderResources};
use crate::shapes::{
Fill, FontFamily, FontStyle, Frame, Group, GrowType, ImageFill, Paragraph, Path, Rect, Segment,
Fill, FontFamily, FontStyle, Frame, Group, GrowType, Paragraph, Path, Rect, Segment,
SolidColor, Stroke, StrokeKind, StrokeStyle, TextAlign, TextContent, TextDirection, TextSpan,
Type,
};
@@ -18,10 +18,6 @@ use super::render_tree_to_svg;
/// Font URL referenced in exported SVG `@font-face` rules.
pub(super) const TEST_FONT_URL: &str = "fonts/sourcesanspro-regular.ttf";
/// Media URL referenced by linked `<image href>` fills in SVG export tests.
/// Relative path so `./preview-snapshots` can resolve it under `target/svg-preview/`.
pub(super) const TEST_IMAGE_URL: &str = "images/test-fill.svg";
fn register_test_font_urls(fonts: &mut FontStore) {
let family = FontFamily::new(Uuid::nil(), 400, FontStyle::Normal);
fonts.set_source_url(&family.alias(), TEST_FONT_URL.to_string());
@@ -32,31 +28,6 @@ pub(super) fn uid(n: u32) -> Uuid {
uuid_from_u32_quartet(0, 0, 0, n)
}
/// Adds a rectangle filled with a linked image (must call `render_with` / register URL).
pub(super) fn add_image_rect(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
image_id: Uuid,
keep_aspect_ratio: bool,
opacity: u8,
) {
add_rect_with_fills(
pool,
id,
parent,
(l, t, r, b),
vec![Fill::Image(ImageFill::new(
image_id,
opacity,
200,
100,
keep_aspect_ratio,
))],
);
}
/// Adds a solid-filled rectangle to the pool.
pub(super) fn add_solid_rect(
pool: &mut ShapesPool,
@@ -97,101 +68,15 @@ pub(super) fn add_frame(
(l, t, r, b): (f32, f32, f32, f32),
color: skia::Color,
clip: bool,
) {
add_frame_with_fills(
pool,
id,
parent,
(l, t, r, b),
vec![Fill::Solid(SolidColor(color))],
clip,
);
}
fn add_frame_with_fills(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
fills: Vec<Fill>,
clip: bool,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Frame(Frame::default()));
shape.set_selrect(l, t, r, b);
shape.set_fills(fills);
shape.set_fills(vec![Fill::Solid(SolidColor(color))]);
shape.set_clip(clip);
}
/// Frame whose background is a linked image fill.
pub(super) fn add_image_frame(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
image_id: Uuid,
clip: bool,
) {
add_frame_with_fills(
pool,
id,
parent,
(l, t, r, b),
vec![test_image_fill(image_id)],
clip,
);
}
fn triangle_segments(closed: bool) -> Vec<Segment> {
let mut segments = vec![
Segment::MoveTo((10.0, 90.0)),
Segment::LineTo((50.0, 10.0)),
Segment::LineTo((90.0, 90.0)),
];
if closed {
segments.push(Segment::Close);
}
segments
}
fn add_path_with_fills(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
(l, t, r, b): (f32, f32, f32, f32),
segments: Vec<Segment>,
fills: Vec<Fill>,
) {
let shape = pool.add_shape(id);
shape.set_parent(parent);
shape.set_shape_type(Type::Path(Path::new(segments)));
shape.set_selrect(l, t, r, b);
shape.set_fills(fills);
}
fn test_image_fill(image_id: Uuid) -> Fill {
Fill::Image(ImageFill::new(image_id, 255, 200, 100, true))
}
/// Triangle path (open or closed) with a linked image fill.
pub(super) fn add_image_path(
pool: &mut ShapesPool,
id: Uuid,
parent: Uuid,
closed: bool,
image_id: Uuid,
) {
add_path_with_fills(
pool,
id,
parent,
(0.0, 0.0, 100.0, 100.0),
triangle_segments(closed),
vec![test_image_fill(image_id)],
);
}
/// Adds an empty (unmasked) group.
pub(super) fn add_group(
pool: &mut ShapesPool,
@@ -364,39 +249,9 @@ fn stroke_with_style(
stroke
}
/// Text with a linked image fill (register URL via `render_with`).
pub(super) fn add_image_text(
pool: &mut ShapesPool,
id: Uuid,
bounds: (f32, f32, f32, f32),
text: &str,
font_size: f32,
image_id: Uuid,
) {
add_text_with_fills(
pool,
id,
bounds,
text,
font_size,
vec![test_image_fill(image_id)],
);
}
pub(super) fn render(pool: &ShapesPool, root: Uuid) -> String {
render_with(pool, root, |_resources| {})
}
/// Like [`render`], but lets the test register extra resources (e.g. image URLs)
/// before export.
pub(super) fn render_with(
pool: &ShapesPool,
root: Uuid,
setup: impl FnOnce(&mut RenderResources),
) -> String {
let mut resources = RenderResources::try_new_headless().expect("headless resources");
register_test_font_urls(&mut resources.fonts);
setup(&mut resources);
let _guard = TestRenderResourcesGuard::install(&mut resources);
let bytes = render_tree_to_svg(&mut resources, &root, pool, 1.0).expect("svg export");
String::from_utf8(bytes).expect("utf8 svg")
@@ -1,12 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 200 100">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2563eb"/>
<stop offset="100%" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<rect width="200" height="100" fill="url(#g)"/>
<circle cx="60" cy="50" r="28" fill="#fbbf24"/>
<rect x="110" y="22" width="70" height="56" rx="8" fill="#f8fafc" opacity="0.9"/>
<text x="145" y="58" text-anchor="middle" font-family="system-ui,sans-serif" font-size="22" font-weight="700" fill="#1e293b">IMG</text>
</svg>

Before

Width:  |  Height:  |  Size: 610 B

+7 -3
View File
@@ -5,7 +5,6 @@ use crate::shapes::{Shape, Stroke};
use crate::state::ShapesPoolRef;
use super::document::{effect_attrs, SvgLayerCanvas};
use super::images::emit_fills;
use super::render_tree;
use crate::render::RenderResources;
@@ -30,9 +29,14 @@ pub(super) fn render_frame(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
}
// Frame background (frame space), with linked `<image>` for image fills.
// Frame background (frame space).
if !element.fills.is_empty() {
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fills(element, &element.fills)?;
canvas.restore();
}
// Children (absolute coords).
-109
View File
@@ -1,109 +0,0 @@
use crate::error::Result;
use crate::render::shape_renderer::ShapeRenderer;
use crate::render::vector::VectorRenderer;
use crate::shapes::{Fill, ImageFill, Shape};
use crate::state::ShapesPoolRef;
use super::document::SvgLayerCanvas;
use crate::render::RenderResources;
/// Emits fills bottom -> top for SVG export.
///
/// Non-image fills go through Skia's SVG canvas. Image fills with a registered
/// source URL become native linked `<image>` elements (see `store_image_url`);
/// without a URL they fall back to Skia (base64-embed) when a CPU image exists.
pub(super) fn emit_fills(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
shape: &Shape,
fills: &[Fill],
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
if fills.is_empty() {
return Ok(());
}
// fills[0] is the topmost layer; draw bottom → top.
for fill in fills.iter().rev() {
match fill {
Fill::Image(image_fill) if shared.images.source_url(&image_fill.id()).is_some() => {
emit_image_fill(builder, shared, shape, image_fill, tree)?;
}
fill => {
let matrix = shape.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fills(shape, std::slice::from_ref(fill))?;
canvas.restore();
}
}
}
Ok(())
}
/// Emits a linked SVG `<image>` clipped to the shape geometry.
///
/// Skia's SVG backend would base64-embed a PNG from `draw_image_rect`; we emit
/// a native `<image href="...">` instead so the export stays linked to the
/// registered media URL (see `store_image_url`).
fn emit_image_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
shape: &Shape,
image_fill: &ImageFill,
tree: ShapesPoolRef,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
};
let clip_id = builder.unique("imgclip");
builder.push_clip_path(&clip_id, shape, tree);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
Ok(())
}
/// Emits `<g clip-path>` + `<image href>` using the shape selrect and page CTM.
pub(super) fn emit_linked_image_element(
builder: &mut SvgLayerCanvas,
shape: &Shape,
image_fill: &ImageFill,
href: &str,
clip_id: &str,
) {
let selrect = shape.selrect();
let opacity = image_fill.opacity() as f32 / 255.0;
let preserve = if image_fill.keep_aspect_ratio() {
"xMidYMid slice"
} else {
"none"
};
let transform = builder.page_shape_matrix_attr(shape);
let opacity_attr = if (opacity - 1.0).abs() < f32::EPSILON {
String::new()
} else {
format!(r#" opacity="{opacity}""#)
};
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
builder.push_raw(&format!(
r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#,
selrect.left(),
selrect.top(),
selrect.width(),
selrect.height(),
));
builder.close_group();
}
pub(super) fn xml_escape_attr(s: &str) -> String {
s.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
+4 -20
View File
@@ -8,8 +8,7 @@ use crate::shapes::{Shape, Type};
use crate::state::ShapesPoolRef;
use crate::uuid::Uuid;
use super::shape_renderer::ShapeRenderer;
use super::vector::VectorRenderer;
use super::vector::{render_leaf_content, VectorRenderer};
use super::RenderResources;
/// Collects the registered font aliases used by every text span in the subtree
@@ -126,7 +125,6 @@ pub(crate) fn render_tree_to_svg(
mod document;
mod frames;
mod groups;
mod images;
mod text;
use document::SvgLayerCanvas;
@@ -135,7 +133,6 @@ use groups::render_group;
use text::render_text_fill;
use document::effect_attrs;
use images::emit_fills;
/// Renders `id`'s subtree to an SVG body, returning `(defs, body)`.
fn render_body(
@@ -175,7 +172,7 @@ fn render_tree(
| Type::Path(_)
| Type::Bool(_)
| Type::Text(_)
| Type::SVGRaw(_) => render_leaf(builder, shared, element, tree, scale),
| Type::SVGRaw(_) => render_leaf(builder, shared, element, scale),
}
}
@@ -183,7 +180,6 @@ fn render_leaf(
builder: &mut SvgLayerCanvas,
shared: &mut RenderResources,
element: &Shape,
tree: ShapesPoolRef,
scale: f32,
) -> Result<()> {
let effects = effect_attrs(element);
@@ -193,26 +189,14 @@ fn render_leaf(
{
if matches!(element.shape_type, Type::Text(_)) {
render_text_fill(builder, shared, element)?;
render_text_fill(builder, element)?;
} else {
emit_fills(builder, shared, element, &element.fills, tree, scale)?;
let matrix = element.centered_transform();
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
let mut renderer = VectorRenderer::new(canvas, shared, scale, false);
renderer.draw_fill_inner_shadows(element)?;
let visible_strokes: Vec<_> = element.visible_strokes().collect();
if !visible_strokes.is_empty() {
renderer.draw_strokes(element, &visible_strokes)?;
if !element.has_fills() {
for stroke in &visible_strokes {
renderer.draw_stroke_inner_shadows(element, stroke)?;
}
}
}
render_leaf_content(&mut renderer, element)?;
canvas.restore();
}
}
@@ -1,8 +0,0 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="80" viewBox="0 0 100 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<rect width="100" height="80"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="80" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
@@ -1,8 +0,0 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90L10 90Z"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
@@ -1,8 +0,0 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120" viewBox="0 0 200 120"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<rect width="200" height="120"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="200" height="120" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
@@ -1,8 +0,0 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80" height="80" viewBox="0 0 80 80"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<path transform="translate(-10 -10)" d="M10 90L50 10L90 90"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="100" height="100" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 -10 -10)"/></g></svg>
@@ -1,11 +0,0 @@
---
source: src/render/svg/tests.rs
assertion_line: 306
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="560" height="240" viewBox="0 0 560 240"><defs><style type="text/css"><![CDATA[@font-face{font-family:"Source Sans Pro";font-style:normal;font-weight:400;src:url("fonts/sourcesanspro-regular.ttf") format("truetype");}]]></style><clipPath id="txtimgclip0" clipPathUnits="userSpaceOnUse">
<text font-size="200" font-family="Source Sans Pro" x="0, 130.37109, 263.08594, 360.83984" y="181">
HOLA
</text>
</clipPath></defs><g clip-path="url(#txtimgclip0)"><image href="images/test-fill.svg" x="0" y="0" width="560" height="240" preserveAspectRatio="xMidYMid slice" transform="matrix(1 0 0 1 0 0)"/></g></svg>
@@ -1,10 +0,0 @@
---
source: src/render/svg/tests.rs
expression: svg
---
<?xml version="1.0" encoding="utf-8" ?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="408" height="128" viewBox="0 0 408 128"><defs><clipPath id="imgclip0" clipPathUnits="userSpaceOnUse">
<rect transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
</clipPath></defs><g clip-path="url(#imgclip0)"><image href="images/test-fill.svg" x="100" y="50" width="408" height="128" preserveAspectRatio="none" opacity="0.5019608" transform="matrix(1 0 0 1 -100 -50)"/></g>
<rect fill="#003FFF" fill-opacity="0.50196081" transform="translate(-100 -50)" x="100" y="50" width="408" height="128"/>
</svg>
+1 -227
View File
@@ -1,6 +1,6 @@
use super::fixtures::*;
use crate::shapes::{BlendMode, Fill, ImageFill, SolidColor, StrokeCap, StrokeKind};
use crate::shapes::{BlendMode, Fill, SolidColor, StrokeCap, StrokeKind};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@@ -962,229 +962,3 @@ fn exports_solid_text_with_font_face() {
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_text() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_text(
&mut pool,
id,
(0.0, 0.0, 560.0, 240.0),
"HOLA",
200.0,
image_id,
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"text image fill must emit a linked <image>: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"text image fill must be clipped to glyph silhouette: {svg}"
);
assert!(
svg.contains("<clipPath ") && svg.contains("<text"),
"clipPath must contain text glyphs: {svg}"
);
assert!(
!svg.contains("data:image"),
"must not base64-embed the image: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_as_linked_image() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_rect(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
image_id,
true,
255,
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image"),
"image fill must emit an <image> element: {svg}"
);
assert!(
svg.contains(TEST_IMAGE_URL),
"image href must use the registered URL: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"keep-aspect image fill must slice: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"image fill must be clipped to shape geometry: {svg}"
);
assert!(
!svg.contains("data:image"),
"must not base64-embed the image: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_mixed_solid_and_image_fills_in_order() {
// Image under a translucent solid; stretch (keep-aspect off); partial image
// opacity; shape not at the page origin (page translate in CTM).
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_rect_with_fills(
&mut pool,
id,
Uuid::nil(),
(100.0, 50.0, 508.0, 178.0),
vec![
// fills[0] topmost — solid blue @ 50%
Fill::Solid(SolidColor(skia::Color::from_argb(128, 0, 63, 255))),
// fills[1] underneath — linked image, stretch, ~50% opacity
Fill::Image(ImageFill::new(image_id, 128, 400, 300, false)),
],
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
let image_pos = svg.find("<image");
let blue_pos = svg.to_ascii_lowercase().find("fill=\"#003fff\"");
assert!(image_pos.is_some(), "missing image fill: {svg}");
assert!(blue_pos.is_some(), "missing top solid fill: {svg}");
assert!(
image_pos.unwrap() < blue_pos.unwrap(),
"image (bottom) must appear before solid (top): {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"none\""),
"mixed image fill should stretch when keep-aspect is off: {svg}"
);
// 128/255 → ~0.50196 as f32 (not a rounded "0.5").
assert!(
svg.contains("opacity=\"0.5019608\""),
"image fill opacity must be emitted: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_closed_path() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_path(&mut pool, id, Uuid::nil(), true, image_id);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"closed path must emit a linked image fill: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"image fill must be clipped to the path: {svg}"
);
// Clip geometry should be a path (triangle), not a plain rect.
assert!(
svg.contains("<path") || svg.contains(" d=\""),
"closed-path clip must use path geometry: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"keep-aspect image fill on path: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_open_path() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_path(&mut pool, id, Uuid::nil(), false, image_id);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"open path must still emit a linked image fill: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"image fill must be clipped to the open path geometry: {svg}"
);
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_on_frame() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_image_frame(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 200.0, 120.0),
image_id,
false,
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains("<image") && svg.contains(TEST_IMAGE_URL),
"frame background must emit a linked image fill: {svg}"
);
assert!(
svg.contains("clip-path=\"url(#"),
"frame image fill must be clipped to the frame: {svg}"
);
assert!(
svg.contains("preserveAspectRatio=\"xMidYMid slice\""),
"frame image fill should keep aspect: {svg}"
);
// No nested board clip when clip_content is off.
assert!(
!svg.contains("id=\"clip0\""),
"unclipped frame should not wrap children in a board clip: {svg}"
);
insta::assert_snapshot!(svg);
}
+11 -114
View File
@@ -1,122 +1,19 @@
use std::collections::HashSet;
use crate::error::Result;
use crate::render::text;
use crate::shapes::{Fill, ImageFill, Shape};
use crate::uuid::Uuid;
use crate::shapes::Shape;
use super::document::SvgLayerCanvas;
use super::images::{emit_linked_image_element, xml_escape_attr};
use crate::render::RenderResources;
use crate::render::text;
/// Emits a text shape's fills for SVG export.
/// Emits a text shape's fill as native `<text>` elements.
///
/// Linked image fills become `<image href>` clipped to the glyph silhouette;
/// other fills go through Skia as native `<text>`. Strokes are a later PR.
pub(super) fn render_text_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
element: &Shape,
) -> Result<()> {
let text_content = element.get_text_content();
let text_content = text_content.new_bounds(element.selrect());
let max_layers = text_content.max_fill_layers();
if max_layers == 0 {
return Ok(());
}
/// The shared GPU/PDF renderer wraps text in `save_layer`, which `SkSVGDevice`
/// silently drops. Text strokes are handled separately in a later PR.
pub(super) fn render_text_fill(builder: &mut SvgLayerCanvas, element: &Shape) -> Result<()> {
let matrix = element.centered_transform();
for layer in 0..max_layers {
let linked = linked_image_fills_at_layer(&text_content, layer, shared);
let skip_ids: HashSet<Uuid> = linked.iter().map(|img| img.id()).collect();
for image_fill in &linked {
emit_text_image_fill(builder, shared, element, image_fill, layer)?;
}
if layer_has_skia_fills(&text_content, layer, &skip_ids) {
let mut paragraph_builders = if skip_ids.is_empty() {
text_content.paragraph_builder_group_for_fill_layer(layer)
} else {
text_content
.paragraph_builder_group_for_fill_layer_skipping_images(layer, &skip_ids)
};
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
text::paint_text_paragraphs(canvas, element, &mut paragraph_builders);
canvas.restore();
}
}
Ok(())
}
fn linked_image_fills_at_layer<'a>(
text_content: &'a crate::shapes::TextContent,
layer: usize,
shared: &RenderResources,
) -> Vec<&'a ImageFill> {
let mut out = Vec::new();
let mut seen = HashSet::new();
for paragraph in text_content.paragraphs() {
for span in paragraph.children() {
if let Some(Fill::Image(img)) = span.fills_from_bottom(layer) {
if shared.images.source_url(&img.id()).is_some() && seen.insert(img.id()) {
out.push(img);
}
}
}
}
out
}
fn layer_has_skia_fills(
text_content: &crate::shapes::TextContent,
layer: usize,
skip_ids: &HashSet<Uuid>,
) -> bool {
text_content.paragraphs().iter().any(|paragraph| {
paragraph
.children()
.iter()
.any(|span| match span.fills_from_bottom(layer) {
Some(Fill::Image(img)) if skip_ids.contains(&img.id()) => false,
Some(_) => true,
None => false,
})
})
}
/// Linked `<image>` clipped to the opaque glyph silhouette for this image layer.
fn emit_text_image_fill(
builder: &mut SvgLayerCanvas,
shared: &RenderResources,
shape: &Shape,
image_fill: &ImageFill,
layer: usize,
) -> Result<()> {
let Some(url) = shared.images.source_url(&image_fill.id()) else {
return Ok(());
};
let clip_id = builder.unique("txtimgclip");
let text_content = shape.get_text_content().new_bounds(shape.selrect());
let mut paragraph_builders =
text_content.paragraph_builder_group_opaque_for_image_layer(layer, image_fill.id());
let canvas = builder.new_fragment();
{
let cv: &skia_safe::Canvas = &canvas;
cv.save();
cv.concat(&shape.centered_transform());
text::paint_text_paragraphs(cv, shape, &mut paragraph_builders);
cv.restore();
}
builder.finish_clip_path_fragment(&clip_id, canvas);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
let canvas = builder.canvas();
canvas.save();
canvas.concat(&matrix);
text::paint_text_fill(canvas, element);
canvas.restore();
Ok(())
}
+16 -7
View File
@@ -530,13 +530,22 @@ fn render_text_on_canvas(
}
}
/// Paints pre-built paragraph groups (SVG export path for selective fill layers).
pub fn paint_text_paragraphs(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
) {
paint_text_with_emoji_overlay(canvas, shape, paragraph_builder_groups, false);
/// Paints text fill for vector SVG export. Skips `save_layer` wrappers that
/// `SkSVGDevice` would drop.
pub fn paint_text_fill(canvas: &Canvas, shape: &Shape) {
let text_content = shape.get_text_content();
let text_content = text_content.new_bounds(shape.selrect());
let max_layers = text_content.max_fill_layers();
if max_layers == 0 {
return;
}
// Each fill layer is painted separately so SkSVGDevice can emit `fill`
// attributes (merged shaders are dropped). Bottom layer first.
for layer in 0..max_layers {
let mut paragraph_builders = text_content.paragraph_builder_group_for_fill_layer(layer);
paint_text_with_emoji_overlay(canvas, shape, &mut paragraph_builders, false);
}
}
/// Lays out and paints paragraph builders without any layer management.
+14 -114
View File
@@ -796,13 +796,13 @@ impl TextContent {
&self,
use_shadow: Option<bool>,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(use_shadow, false, None, None, None, None)
self.paragraph_builders(use_shadow, false, None, None)
}
/// Creates paragraph builders with always-opaque paint (BLACK @ alpha 255).
/// Used as a clip mask for inner stroke rendering.
pub fn paragraph_builder_group_opaque(&self) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(None, true, None, None, None, None)
self.paragraph_builders(None, true, None, None)
}
/// Maximum number of stacked fills across every span in this text block.
@@ -821,42 +821,7 @@ impl TextContent {
&self,
layer_from_bottom: usize,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(None, false, None, Some(layer_from_bottom), None, None)
}
/// Like [`paragraph_builder_group_for_fill_layer`], but spans whose fill at
/// this layer is an image in `skip_image_ids` get transparent paint (those
/// fills are re-emitted as linked SVG `<image>` elements).
pub fn paragraph_builder_group_for_fill_layer_skipping_images(
&self,
layer_from_bottom: usize,
skip_image_ids: &HashSet<Uuid>,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(
None,
false,
None,
Some(layer_from_bottom),
None,
Some(skip_image_ids),
)
}
/// Opaque black glyphs only for spans whose fill at `layer_from_bottom` is
/// the given image — used as an SVG `<clipPath>` for linked image fills.
pub fn paragraph_builder_group_opaque_for_image_layer(
&self,
layer_from_bottom: usize,
image_id: Uuid,
) -> Vec<ParagraphBuilderGroup> {
self.paragraph_builders(
None,
false,
None,
None,
Some((layer_from_bottom, image_id)),
None,
)
self.paragraph_builders(None, false, None, Some(layer_from_bottom))
}
fn paragraph_builders(
@@ -865,8 +830,6 @@ impl TextContent {
opaque: bool,
align_override: Option<skia::textlayout::TextAlign>,
fill_layer: Option<usize>,
opaque_image_layer: Option<(usize, Uuid)>,
skip_image_ids: Option<&HashSet<Uuid>>,
) -> Vec<ParagraphBuilderGroup> {
let fonts = get_font_collection();
let fallback_fonts = get_fallback_fonts();
@@ -880,63 +843,15 @@ impl TextContent {
let mut builder = ParagraphBuilder::new(&paragraph_style, fonts);
let mut has_text = false;
for span in paragraph.children() {
let text_style = if let Some((layer, image_id)) = opaque_image_layer {
let mut style = span.to_style(
&self.bounds(),
fallback_fonts,
false,
paragraph.line_height(),
);
let mut paint = paint::Paint::default();
match span.fills_from_bottom(layer) {
Some(shapes::Fill::Image(img)) if img.id() == image_id => {
paint.set_color(skia::Color::BLACK);
paint.set_alpha(255);
}
_ => {
paint.set_color(skia::Color::TRANSPARENT);
}
}
style.set_foreground_paint(&paint);
style
} else if let (Some(layer), Some(skip)) = (fill_layer, skip_image_ids) {
let skip_span = matches!(
span.fills_from_bottom(layer),
Some(shapes::Fill::Image(img)) if skip.contains(&img.id())
);
if skip_span {
let mut style = span.to_style(
&self.bounds(),
fallback_fonts,
false,
paragraph.line_height(),
);
let mut paint = paint::Paint::default();
paint.set_color(skia::Color::TRANSPARENT);
style.set_foreground_paint(&paint);
style
} else {
let remove_alpha =
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
remove_alpha,
paragraph.line_height(),
fill_layer,
)
}
} else {
let remove_alpha =
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
remove_alpha,
paragraph.line_height(),
fill_layer,
)
};
let remove_alpha =
opaque || (use_shadow.unwrap_or(false) && !span.is_transparent());
let text_style = span.to_style_with_paint(
&self.bounds(),
fallback_fonts,
remove_alpha,
paragraph.line_height(),
fill_layer,
);
let text: String = span.apply_text_transform();
if !text.is_empty() {
has_text = true;
@@ -956,14 +871,8 @@ impl TextContent {
/// Performs an Auto Width text layout.
fn text_layout_auto_width(&self) -> TextContentLayoutResult {
// Left-aligned MAX-width pass: longest_line() is glyph width, not the huge container.
let mut measure_builders = self.paragraph_builders(
None,
false,
Some(skia::textlayout::TextAlign::Left),
None,
None,
None,
);
let mut measure_builders =
self.paragraph_builders(None, false, Some(skia::textlayout::TextAlign::Left), None);
let normalized_line_height =
calculate_normalized_line_height(&mut measure_builders, f32::MAX);
@@ -1555,15 +1464,6 @@ pub struct TextSpan {
}
impl TextSpan {
/// Fill at `layer` counting from the bottom (`0` = last / bottommost fill).
pub fn fills_from_bottom(&self, layer: usize) -> Option<&shapes::Fill> {
if layer < self.fills.len() {
Some(&self.fills[self.fills.len() - 1 - layer])
} else {
None
}
}
#[allow(clippy::too_many_arguments)]
pub fn new(
text: String,
-16
View File
@@ -140,22 +140,6 @@ pub extern "C" fn store_image() -> Result<()> {
Ok(())
}
/// Registers the public URL an image was loaded from for SVG export.
///
/// Layout: UTF-8 URL bytes in the alloc buffer. The image UUID is passed as
/// the four u32 arguments (same quartet as `store_image` / `is_image_cached`).
#[no_mangle]
#[wasm_error]
pub extern "C" fn store_image_url(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
let id = uuid_from_u32_quartet(a, b, c, d);
let url_bytes = mem::bytes();
let url = String::from_utf8(url_bytes)
.map_err(|_| Error::CriticalError("Invalid UTF-8 in image source URL".to_string()))?;
mem::free_bytes()?;
get_resources().images.set_source_url(id, url);
Ok(())
}
/// Stores an image from an existing WebGL texture, avoiding re-decoding
/// Expected memory layout:
/// - bytes 0-15: shape UUID