mirror of
https://github.com/penpot/penpot.git
synced 2026-08-07 05:18:36 +00:00
✨ Add shape compact/expand module for binfile-v3 format
Create common/src/app/common/files/shape_compact.cljc with: - compact-shape: prune derivable geometry, identity transforms, nil values, defaults, and empty collections - expand-shape: restore omitted fields via existing setup-rect/setup-path - round-values: round float32 artifacts to 4 decimal places Includes 24 tests (unit + generative) over schema:shape. AI-assisted-by: deepseek-v4-pro
This commit is contained in:
parent
d04cbf175e
commit
c5a29e27a6
145
common/src/app/common/files/shape_compact.cljc
Normal file
145
common/src/app/common/files/shape_compact.cljc
Normal file
@ -0,0 +1,145 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns app.common.files.shape-compact
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.geom.matrix :as gmt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.geom.shapes :as gsh]
|
||||
[app.common.math :as mth]
|
||||
[app.common.types.shape :as cts]))
|
||||
|
||||
#?(:clj (set! *warn-on-reflection* true))
|
||||
|
||||
;; --- Identity transform detection (handles both Matrix instances and plain maps)
|
||||
|
||||
(defn- identity-transform?
|
||||
[transform]
|
||||
(if (nil? transform)
|
||||
true
|
||||
(if (gmt/matrix? transform)
|
||||
(gmt/unit? transform)
|
||||
(and (mth/close? (d/nilv (:a transform) 1) 1)
|
||||
(mth/close? (d/nilv (:b transform) 0) 0)
|
||||
(mth/close? (d/nilv (:c transform) 0) 0)
|
||||
(mth/close? (d/nilv (:d transform) 1) 1)
|
||||
(mth/close? (d/nilv (:e transform) 0) 0)
|
||||
(mth/close? (d/nilv (:f transform) 0) 0)))))
|
||||
|
||||
;; --- Default values that can be omitted
|
||||
|
||||
(def ^:private default-attrs
|
||||
{:rotation 0
|
||||
:proportion-lock false
|
||||
:hide-fill-on-export false
|
||||
:hide-in-viewer false
|
||||
:show-content false
|
||||
:blocked false
|
||||
:collapsed false
|
||||
:locked false
|
||||
:hidden false
|
||||
:masked-group false
|
||||
:fixed-scroll false
|
||||
:grow-type :fixed})
|
||||
|
||||
;; --- Empty collections that can be omitted these are optional attrs with
|
||||
;; default behavior when absent
|
||||
|
||||
(def ^:private empty-collections
|
||||
[:fills :strokes :shadow :exports :interactions :grids :shapes])
|
||||
|
||||
;; --- Compaction
|
||||
|
||||
(defn compact-shape
|
||||
"Prune redundant and derivable fields from a shape for compact serialization.
|
||||
Omits:
|
||||
- nil values
|
||||
- identity transform and transform-inverse
|
||||
- selrect and points when derivable from x/y/width/height (non-rotated shapes)
|
||||
- page-id (redundant with the containing page)
|
||||
- default values (rotation 0, proportion-lock false, etc.)
|
||||
- empty collections (fills, strokes, shadow, etc.)
|
||||
The resulting shape can be restored via expand-shape."
|
||||
[shape]
|
||||
;; Convert to plain map first (cr/defrecord does not fully remove
|
||||
;; declared fields on dissoc; it sets them to nil instead).
|
||||
(let [shape (reduce-kv (fn [m k v]
|
||||
(if (nil? v) m (assoc m k v)))
|
||||
{} shape)
|
||||
id-xf? (identity-transform? (:transform shape))
|
||||
type (dm/get-prop shape :type)
|
||||
path? (or (= type :path) (= type :bool))
|
||||
rot (dm/get-prop shape :rotation)
|
||||
safe? (and id-xf? (or (nil? rot) (zero? rot)))]
|
||||
(-> shape
|
||||
(dissoc :page-id)
|
||||
(cond-> id-xf?
|
||||
(dissoc :transform :transform-inverse))
|
||||
(cond-> (and safe? (not path?))
|
||||
(dissoc :selrect :points))
|
||||
(cond-> (and id-xf? path?)
|
||||
(dissoc :points))
|
||||
(as-> s
|
||||
(reduce-kv (fn [s k v]
|
||||
(let [d (get default-attrs k ::nf)]
|
||||
(if (and (not= d ::nf) (= v d))
|
||||
(dissoc s k)
|
||||
s)))
|
||||
s s))
|
||||
(as-> s
|
||||
(reduce (fn [s k]
|
||||
(let [v (get s k)]
|
||||
(if (and (coll? v) (empty? v))
|
||||
(dissoc s k)
|
||||
s)))
|
||||
s empty-collections)))))
|
||||
|
||||
;; --- Expansion
|
||||
|
||||
(defn expand-shape
|
||||
"Restore fields omitted by compact-shape. Restores identity
|
||||
transform/transform-inverse, and recomputes selrect and points using
|
||||
the standard shape setup functions. Returns a Shape record."
|
||||
[shape]
|
||||
(let [type (dm/get-prop shape :type)
|
||||
path? (or (= type :path) (= type :bool))]
|
||||
(-> shape
|
||||
(cond-> (nil? (:transform shape))
|
||||
(assoc :transform (gmt/matrix)))
|
||||
(cond-> (nil? (:transform-inverse shape))
|
||||
(assoc :transform-inverse (gmt/matrix)))
|
||||
(as-> s
|
||||
(if path?
|
||||
(cts/setup-path s)
|
||||
(cts/setup-rect s)))
|
||||
(cts/create-shape))))
|
||||
|
||||
;; --- Float rounding
|
||||
|
||||
(defn round-values
|
||||
"Round all numeric values in data to 4 decimal places.
|
||||
Eliminates float32 artifacts like 0.6000000238418579."
|
||||
[data]
|
||||
(letfn [(round-n [n]
|
||||
(if (and (number? n) (mth/finite? n) (not (integer? n)))
|
||||
(mth/precision n 4)
|
||||
n))
|
||||
(walk [node]
|
||||
(cond
|
||||
(map? node)
|
||||
(reduce-kv (fn [m k v] (assoc m k (walk v))) node node)
|
||||
|
||||
(vector? node)
|
||||
(mapv walk node)
|
||||
|
||||
(number? node)
|
||||
(round-n node)
|
||||
|
||||
:else
|
||||
node))]
|
||||
(walk data)))
|
||||
250
common/test/common_tests/files_shape_compact_test.cljc
Normal file
250
common/test/common_tests/files_shape_compact_test.cljc
Normal file
@ -0,0 +1,250 @@
|
||||
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
;;
|
||||
;; Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||
|
||||
(ns common-tests.files-shape-compact-test
|
||||
(:require
|
||||
[app.common.files.shape-compact :as sc]
|
||||
[app.common.geom.matrix :as gmt]
|
||||
[app.common.geom.point :as gpt]
|
||||
[app.common.geom.rect :as grc]
|
||||
[app.common.math :as mth]
|
||||
[app.common.schema :as sm]
|
||||
[app.common.schema.generators :as sg]
|
||||
[app.common.schema.test :as smt]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
;; --- Helpers
|
||||
|
||||
(defn- make-rect
|
||||
[props]
|
||||
(cts/setup-shape (merge {:type :rect :x 0 :y 0 :width 10 :height 10} props)))
|
||||
|
||||
(defn- make-path
|
||||
[props]
|
||||
(cts/setup-shape
|
||||
(merge {:type :path
|
||||
:content [{:command :move-to :params {:x 0 :y 0}}
|
||||
{:command :line-to :params {:x 10 :y 10}}]}
|
||||
props)))
|
||||
|
||||
;; --- round-values
|
||||
|
||||
(t/deftest round-values-integers-unchanged
|
||||
(t/is (= 5 (sc/round-values 5)))
|
||||
(t/is (= 0 (sc/round-values 0))))
|
||||
|
||||
(t/deftest round-values-floats-rounded
|
||||
(t/is (= 0.6 (sc/round-values 0.6000000238418579)))
|
||||
(t/is (= 4213.6 (sc/round-values 4213.60009765625)))
|
||||
(t/is (= 0.1235 (sc/round-values 0.12345678))))
|
||||
|
||||
(t/deftest round-values-nested-maps
|
||||
(let [data {:x 100.1234567 :y 200.9876543
|
||||
:meta {:opacity 0.6000000238418579}}]
|
||||
(t/is (= {:x 100.1235 :y 200.9877
|
||||
:meta {:opacity 0.6}}
|
||||
(sc/round-values data)))))
|
||||
|
||||
(t/deftest round-values-vectors
|
||||
(let [data [1.2345678 2.00001 3]]
|
||||
(t/is (= [1.2346 2.0 3] (sc/round-values data)))))
|
||||
|
||||
(t/deftest round-values-non-numeric-unchanged
|
||||
(t/is (= "hello" (sc/round-values "hello")))
|
||||
(t/is (= :foo (sc/round-values :foo)))
|
||||
(t/is (= true (sc/round-values true)))
|
||||
(t/is (nil? (sc/round-values nil)))
|
||||
(let [id (uuid/next)]
|
||||
(t/is (= id (sc/round-values id)))))
|
||||
|
||||
;; --- compact-shape
|
||||
|
||||
(t/deftest compact-shape-removes-nils
|
||||
(let [shape {:id (uuid/next) :type :rect :name "test"
|
||||
:flip-x nil :flip-y nil
|
||||
:fills [] :strokes []}
|
||||
c (sc/compact-shape shape)]
|
||||
(t/is (not (contains? c :flip-x)))
|
||||
(t/is (not (contains? c :flip-y)))
|
||||
(t/is (not (contains? c :fills)))
|
||||
(t/is (not (contains? c :strokes)))))
|
||||
|
||||
(t/deftest compact-shape-removes-identity-transform
|
||||
(let [shape (make-rect {:x 100 :y 200 :width 50 :height 30})
|
||||
c (sc/compact-shape shape)]
|
||||
(t/is (not (contains? c :transform)))
|
||||
(t/is (not (contains? c :transform-inverse)))))
|
||||
|
||||
(t/deftest compact-shape-keeps-non-identity-transform
|
||||
(let [shape (-> (make-rect {:x 100 :y 200 :width 50 :height 30})
|
||||
(assoc :transform (gmt/matrix 1 0.5 0 1 0 0)))
|
||||
c (sc/compact-shape shape)]
|
||||
(t/is (contains? c :transform))
|
||||
(t/is (contains? c :transform-inverse))))
|
||||
|
||||
(t/deftest compact-shape-removes-derivable-geometry-rect
|
||||
(let [shape (make-rect {:x 100 :y 200 :width 50 :height 30})
|
||||
c (sc/compact-shape shape)]
|
||||
(t/is (not (contains? c :selrect)))
|
||||
(t/is (not (contains? c :points)))))
|
||||
|
||||
(t/deftest compact-shape-keeps-geometry-with-rotation
|
||||
(let [shape (-> (make-rect {:x 100 :y 200 :width 50 :height 30})
|
||||
(assoc :rotation 45))
|
||||
c (sc/compact-shape shape)]
|
||||
(t/is (contains? c :selrect))
|
||||
(t/is (contains? c :points))))
|
||||
|
||||
(t/deftest compact-shape-path-keeps-selrect
|
||||
(let [shape (make-path {})
|
||||
c (sc/compact-shape shape)]
|
||||
(t/is (contains? c :selrect))
|
||||
(t/is (not (contains? c :points)))
|
||||
(t/is (contains? c :content))))
|
||||
|
||||
(t/deftest compact-shape-removes-page-id
|
||||
(let [shape (assoc (make-rect {}) :page-id (uuid/next))
|
||||
c (sc/compact-shape shape)]
|
||||
(t/is (not (contains? c :page-id)))))
|
||||
|
||||
(t/deftest compact-shape-removes-defaults
|
||||
(let [shape (make-rect {:rotation 0 :proportion-lock false :blocked false})
|
||||
c (sc/compact-shape shape)]
|
||||
(t/is (not (contains? c :rotation)))
|
||||
(t/is (not (contains? c :proportion-lock)))
|
||||
(t/is (not (contains? c :blocked)))))
|
||||
|
||||
(t/deftest compact-shape-keeps-non-defaults
|
||||
(let [shape (make-rect {:rotation 30 :blocked true :opacity 0.5})
|
||||
c (sc/compact-shape shape)]
|
||||
(t/is (= 30 (:rotation c)))
|
||||
(t/is (true? (:blocked c)))
|
||||
(t/is (= 0.5 (:opacity c)))))
|
||||
|
||||
(t/deftest compact-shape-preserves-component-attrs
|
||||
(let [comp-id (uuid/next)
|
||||
ref-id (uuid/next)
|
||||
shape (-> (make-rect {})
|
||||
(assoc :component-id comp-id
|
||||
:component-file (uuid/next)
|
||||
:shape-ref ref-id
|
||||
:touched #{:geometry-group}))
|
||||
c (sc/compact-shape shape)]
|
||||
(t/is (= comp-id (:component-id c)))
|
||||
(t/is (= ref-id (:shape-ref c)))
|
||||
(t/is (= #{:geometry-group} (:touched c)))))
|
||||
|
||||
;; --- expand-shape
|
||||
|
||||
(t/deftest expand-shape-restores-transform
|
||||
(let [compact {:id (uuid/next) :type :rect :name "test"
|
||||
:x 100 :y 200 :width 50 :height 30
|
||||
:frame-id uuid/zero :parent-id uuid/zero}
|
||||
expanded (sc/expand-shape compact)]
|
||||
(t/is (gmt/matrix? (:transform expanded)))
|
||||
(t/is (gmt/matrix? (:transform-inverse expanded)))
|
||||
(t/is (true? (gmt/unit? (:transform expanded))))))
|
||||
|
||||
(t/deftest expand-shape-restores-selrect-rect
|
||||
(let [compact {:id (uuid/next) :type :rect :name "test"
|
||||
:x 100 :y 200 :width 50 :height 30
|
||||
:frame-id uuid/zero :parent-id uuid/zero}
|
||||
expanded (sc/expand-shape compact)]
|
||||
(t/is (grc/rect? (:selrect expanded)))
|
||||
(t/is (= 100 (:x (:selrect expanded))))
|
||||
(t/is (= 200 (:y (:selrect expanded))))
|
||||
(t/is (= 50 (:width (:selrect expanded))))
|
||||
(t/is (= 30 (:height (:selrect expanded))))))
|
||||
|
||||
(t/deftest expand-shape-restores-points
|
||||
(let [compact {:id (uuid/next) :type :rect :name "test"
|
||||
:x 100 :y 200 :width 50 :height 30
|
||||
:frame-id uuid/zero :parent-id uuid/zero}
|
||||
expanded (sc/expand-shape compact)
|
||||
points (:points expanded)]
|
||||
(t/is (vector? points))
|
||||
(t/is (= 4 (count points)))
|
||||
(t/is (every? gpt/point? points))))
|
||||
|
||||
(t/deftest expand-shape-path
|
||||
(let [compact {:id (uuid/next) :type :path :name "test"
|
||||
:selrect (grc/make-rect 0 0 10 10)
|
||||
:content [{:command :move-to :params {:x 0 :y 0}}
|
||||
{:command :line-to :params {:x 10 :y 10}}]
|
||||
:frame-id uuid/zero :parent-id uuid/zero}
|
||||
expanded (sc/expand-shape compact)]
|
||||
(t/is (grc/rect? (:selrect expanded)))
|
||||
(t/is (vector? (:points expanded)))
|
||||
(t/is (= 4 (count (:points expanded))))))
|
||||
|
||||
;; --- Round-trip
|
||||
|
||||
(t/deftest compact-expand-roundtrip-rect
|
||||
(let [shape (make-rect {:x 123 :y 456 :width 78 :height 90})
|
||||
compact (sc/compact-shape shape)
|
||||
result (sc/expand-shape compact)]
|
||||
(t/is (= (:id shape) (:id result)))
|
||||
(t/is (= (:type shape) (:type result)))
|
||||
(t/is (= (:name shape) (:name result)))
|
||||
(t/is (= (:x shape) (:x result)))
|
||||
(t/is (= (:y shape) (:y result)))
|
||||
(t/is (= (:width shape) (:width result)))
|
||||
(t/is (= (:height shape) (:height result)))
|
||||
(t/is (grc/rect? (:selrect result)))
|
||||
(t/is (gmt/matrix? (:transform result)))
|
||||
(t/is (sm/validate cts/schema:shape result))))
|
||||
|
||||
(t/deftest compact-expand-roundtrip-preserves-ids
|
||||
(let [id (uuid/next)
|
||||
frame-id (uuid/next)
|
||||
parent-id (uuid/next)
|
||||
shape (-> (make-rect {:x 10 :y 20 :width 30 :height 40})
|
||||
(assoc :id id :frame-id frame-id :parent-id parent-id))
|
||||
compact (sc/compact-shape shape)
|
||||
result (sc/expand-shape compact)]
|
||||
(t/is (= id (:id result)))
|
||||
(t/is (= frame-id (:frame-id result)))
|
||||
(t/is (= parent-id (:parent-id result)))))
|
||||
|
||||
;; --- Generative
|
||||
|
||||
(t/deftest compact-expand-schema-valid
|
||||
(smt/check!
|
||||
(smt/for [shape (sg/generator cts/schema:shape)]
|
||||
(let [compact (sc/compact-shape shape)
|
||||
expanded (sc/expand-shape compact)]
|
||||
(sm/validate cts/schema:shape expanded)))
|
||||
{:num 200}))
|
||||
|
||||
(t/deftest compact-expand-preserves-geometry
|
||||
(smt/check!
|
||||
(smt/for [shape (sg/generator cts/schema:shape)]
|
||||
(let [compact (sc/compact-shape shape)
|
||||
expanded (sc/expand-shape compact)]
|
||||
(and (= (:id shape) (:id expanded))
|
||||
(= (:type shape) (:type expanded))
|
||||
(= (:name shape) (:name expanded))
|
||||
(= (:frame-id shape) (:frame-id expanded))
|
||||
(= (:parent-id shape) (:parent-id expanded))
|
||||
(= (:component-id shape) (:component-id expanded))
|
||||
(= (:component-file shape) (:component-file expanded))
|
||||
(= (:shape-ref shape) (:shape-ref expanded))
|
||||
(= (:touched shape) (:touched expanded)))))
|
||||
{:num 200}))
|
||||
|
||||
(t/deftest compact-expand-preserves-content
|
||||
(smt/check!
|
||||
(smt/for [shape (sg/generator cts/schema:shape)]
|
||||
(let [compact (sc/compact-shape shape)
|
||||
expanded (sc/expand-shape compact)]
|
||||
(or (not= :path (:type shape))
|
||||
(and (= (:id shape) (:id expanded))
|
||||
(= (:content shape) (:content expanded))
|
||||
(some? (:selrect expanded))
|
||||
(vector? (:points expanded))))))
|
||||
{:num 50}))
|
||||
Loading…
x
Reference in New Issue
Block a user