Compare commits

...
Author SHA1 Message Date
Andrey Antukh 29345fd381 ♻️ Replace format string with version integer in library export API
Use version (1 for legacy, 2 for compact) instead of format strings
("compact" / "legacy") throughout the export pipeline. Matches the
backend pattern where version alone determines format behavior. Added
validation that rejects invalid versions with a clear error.

AI-assisted-by: deepseek-v4-pro
2026-07-31 13:28:22 +02:00
Andrey Antukh 8504bb58bf ♻️ Remove unused namespace requires in shape-compact module
AI-assisted-by: deepseek-v4-pro
2026-07-31 13:28:22 +02:00
Andrey Antukh aabf1b0bdb Add library compact export, tests, and manifest cleanup
Embed compact shapes in page entries for library exports, add geometry
verification and structure tests for backend, and remove the redundant
:format field from the manifest — version alone determines format.

AI-assisted-by: deepseek-v4-pro
2026-07-31 13:28:22 +02:00
Andrey Antukh 54c692d10c Add compact binfile-v3 export and import
Add feature-flagged compact export format where pages embed shapes
as a single JSON entry instead of one ZIP entry per shape. Shapes
go through compact-shape (prune defaults/nils/identity transforms)
and round-values (round floats to 4 decimals) before encoding.

Export: one JSON per page with embedded objects, manifest v2,
no pretty-printing. Gated by :binfile-v3-compact flag (opt-in).

Import: detect manifest version 2, expand compact shapes
(restore transforms, recompute selrect/points) on read.

AI-assisted-by: deepseek-v4-pro
2026-07-31 13:28:22 +02:00
Andrey Antukh b59b259a77 ♻️ Remove unnecessary page-id stamping on shapes
Stop stamping :page-id onto shape objects in three places:

- files_thumbnails.clj: get-thumbnail-frame now returns [frame page-id]
  pair instead of stamping the page-id onto the frame itself
- binfile/v3.clj: remove stamp on export (import derives page-id from
  zip entry path, never reads it from the shape)
- library/export.cljs: same — remove stamp on library export

The page-id is always available from context in these code paths.

AI-assisted-by: deepseek-v4-pro
2026-07-31 13:28:22 +02:00
Andrey Antukh c5a29e27a6 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
2026-07-31 13:28:22 +02:00
8 changed files with 802 additions and 62 deletions

No files matched your search

+56 -26
View File
@@ -15,6 +15,7 @@
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.files.migrations :as-alias fmg]
[app.common.files.shape-compact :as fsc]
[app.common.json :as json]
[app.common.logging :as l]
[app.common.media :as cmedia]
@@ -45,6 +46,7 @@
java.io.InputStream
java.io.OutputStreamWriter
java.lang.AutoCloseable
java.nio.charset.StandardCharsets
java.util.zip.ZipEntry
java.util.zip.ZipFile
java.util.zip.ZipOutputStream))
@@ -217,6 +219,16 @@
(.flush writer))
(.closeEntry output))
(defn- write-compact-entry!
[^ZipOutputStream output ^String path data]
(.putNextEntry output (ZipEntry. path))
(let [sw (java.io.StringWriter.)]
(json/write sw data :indent false :key-fn json/write-camel-key)
(.flush sw)
(let [^bytes bytes (.getBytes ^String (str sw) StandardCharsets/UTF_8)]
(.write output bytes 0 (alength bytes))))
(.closeEntry output))
(defn- get-file
[{:keys [::bfc/embed-assets ::bfc/include-libraries] :as cfg} file-id]
@@ -302,23 +314,29 @@
(doseq [[index page-id] (d/enumerate pages)]
(let [path (str "files/" file-id "/pages/" page-id ".json")
page (get pages-index page-id)
objects (:objects page)
page (-> page
(dissoc :objects)
(assoc :index index))
page (encode-page page)]
(write-entry! output path page)
(events/tap :progress {:section :page :id page-id :name (:name page) :file-id file-id})
(doseq [[shape-id shape] objects]
(let [path (str "files/" file-id "/pages/" page-id "/" shape-id ".json")
shape (assoc shape :page-id page-id)
shape (encode-shape shape)]
(write-entry! output path shape)))))
(let [page (get pages-index page-id)
objects (:objects page)]
(if (contains? cf/flags :binfile-v3-compact)
(let [path (str "files/" file-id "/pages/" page-id ".json")
objects (d/update-vals objects
(fn [shape]
(-> shape fsc/compact-shape fsc/round-values encode-shape)))
page (-> page
(assoc :objects objects :index index)
(dissoc :options))]
(events/tap :progress {:section :page :id page-id :name (:name page) :file-id file-id})
(write-compact-entry! output path page))
(let [path (str "files/" file-id "/pages/" page-id ".json")
page (-> page
(dissoc :objects)
(assoc :index index))
page (encode-page page)]
(write-entry! output path page)
(events/tap :progress {:section :page :id page-id :name (:name page) :file-id file-id})
(doseq [[shape-id shape] objects]
(let [path (str "files/" file-id "/pages/" page-id "/" shape-id ".json")
shape (encode-shape shape)]
(write-entry! output path shape)))))))
(vswap! bfc/*state* bfc/collect-storage-objects media)
(vswap! bfc/*state* bfc/collect-storage-objects thumbnails)
@@ -372,11 +390,12 @@
(defn- export-files
[{:keys [::bfc/ids ::bfc/include-libraries ::output] :as cfg}]
(let [ids (into ids (when include-libraries (bfc/get-libraries cfg ids)))
rels (if include-libraries
(->> (bfc/get-files-rels cfg ids)
(mapv (juxt :file-id :library-file-id)))
[])]
(let [ids (into ids (when include-libraries (bfc/get-libraries cfg ids)))
rels (if include-libraries
(->> (bfc/get-files-rels cfg ids)
(mapv (juxt :file-id :library-file-id)))
[])
compact? (contains? cf/flags :binfile-v3-compact)]
(vswap! bfc/*state* assoc :files (d/ordered-map))
@@ -390,7 +409,7 @@
;; Write manifest file
(let [files (:files @bfc/*state*)
params {:type "penpot/export-files"
:version 1
:version (if compact? 2 1)
:generated-by (str "penpot/" (:full cf/version))
:refer "penpot"
:files (vec (vals files))
@@ -686,7 +705,7 @@
(not-empty)))
(defn- read-file-pages
[{:keys [::bfc/input ::entries] :as cfg} file-id]
[{:keys [::bfc/input ::entries ::compact?] :as cfg} file-id]
(->> (keep (match-page-entry-fn file-id) entries)
(keep (fn [{:keys [id entry]}]
(let [page (->> (read-entry input entry)
@@ -694,8 +713,17 @@
page (dissoc page :options)]
(events/tap :progress {:section :page :id id :file-id file-id})
(when (= id (:id page))
(let [objects (read-file-shapes cfg file-id id)]
(assoc page :objects objects))))))
(if compact?
(let [objects (d/update-vals (:objects page)
(fn [shape]
(-> shape
(bfl/clean-shape-pre-decode)
(decode-shape)
(fsc/expand-shape)
(bfl/clean-shape-post-decode))))]
(assoc page :objects objects))
(let [objects (read-file-shapes cfg file-id id)]
(assoc page :objects objects)))))))
(sort-by :index)
(reduce (fn [result {:keys [id] :as page}]
(assoc result id (dissoc page :index)))
@@ -937,10 +965,12 @@
(let [manifest (-> (read-manifest input)
(validate-manifest))
compact? (= 2 (:version manifest))
entries (read-zip-entries input)
cfg (-> cfg
(assoc ::entries entries)
(assoc ::manifest manifest)
(assoc ::compact? compact?)
(assoc ::bfc/timestamp timestamp))]
(when-not (= "penpot/export-files" (:type manifest))
@@ -98,14 +98,13 @@
(defn get-file-data-for-thumbnail
[{:keys [::db/conn] :as cfg} {:keys [data id] :as file} strip-frames-with-thumbnails]
(letfn [;; function responsible on finding the frame marked to be
;; used as thumbnail; the returned frame always have
;; the :page-id set to the page that it belongs.
;; used as thumbnail; returns a [frame page-id] pair.
(get-thumbnail-frame [{:keys [data]}]
(d/seek #(or (:use-for-thumbnail %)
(:use-for-thumbnail? %)) ; NOTE: backward comp (remove on v1.21)
(d/seek (fn [[frame]] (or (:use-for-thumbnail frame)
(:use-for-thumbnail? frame))) ; NOTE: backward comp (remove on v1.21)
(for [page (-> data :pages-index vals)
frame (-> page :objects ctt/get-frames)]
(assoc frame :page-id (:id page)))))
[frame (:id page)])))
;; function responsible to filter objects data structure of
;; all unneeded shapes if a concrete frame is provided. If no
@@ -152,9 +151,9 @@
objects)))]
(let [frame (get-thumbnail-frame file)
(let [[frame page-id] (get-thumbnail-frame file)
frame-id (:id frame)
page-id (or (:page-id frame)
page-id (or page-id
(-> data :pages first))
page (dm/get-in data [:pages-index page-id])
+189 -1
View File
@@ -9,22 +9,29 @@
(:require
[app.binfile.common :as bfc]
[app.binfile.v3 :as v3]
[app.common.data.macros :as dm]
[app.common.features :as cfeat]
[app.common.json :as json]
[app.common.math :as mth]
[app.common.pprint :as pp]
[app.common.thumbnails :as thc]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.http :as http]
[app.rpc :as-alias rpc]
[app.storage :as sto]
[app.storage.tmp :as tmp]
[app.util.blob :as blob]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]
[datoteka.fs :as fs]
[datoteka.io :as io]))
[datoteka.io :as io])
(:import
java.util.zip.ZipFile))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
@@ -86,6 +93,51 @@
(dissoc file :data)))
(defn- prepare-simple-file-with-ids
[profile]
(let [page-id-1 (uuid/custom 1 1)
page-id-2 (uuid/custom 1 2)
shape-id (uuid/custom 2 1)
file (th/create-file* 1 {:profile-id (:id profile)
:project-id (:default-project-id profile)
:is-shared false})]
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-page
:name "test 1"
:id page-id-1}
{:type :add-page
:name "test 2"
:id page-id-2}])
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id-1
:id shape-id
:parent-id uuid/zero
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id shape-id
:name "image"
:frame-id uuid/zero
:parent-id uuid/zero
:type :rect})}])
{:file-id (:id file)
:page-id-1 page-id-1
:page-id-2 page-id-2
:shape-id shape-id}))
(t/deftest export-binfile-v3
(let [profile (th/create-profile* 1)
file (prepare-simple-file profile)
@@ -105,3 +157,139 @@
(v3/import-files!))]
(t/is (= (count result) 1))
(t/is (every? uuid? result)))))
(t/deftest export-binfile-v3-compact
(let [profile (th/create-profile* 1)
{:keys [file-id page-id-1 shape-id]} (prepare-simple-file-with-ids profile)
file {:id file-id}
output (tmp/tempfile :suffix ".zip")]
(with-redefs [cf/flags (conj cf/flags :binfile-v3-compact)]
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{file-id})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output)))
;; Verify manifest version
(let [manifest (v3/get-manifest (str output))]
(t/is (= 2 (:version manifest))))
(let [result (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input output)
(v3/import-files!))]
(t/is (= (count result) 1))
(t/is (every? uuid? result))
;; Verify imported data has correct shapes with preserved geometry
(let [imported-id (first result)
imported-data (-> (bfc/get-file th/*system* imported-id {:realize? true})
:data)
pages-index (get imported-data :pages-index)
page (get pages-index page-id-1)
shape (get-in page [:objects shape-id])]
(t/is page "page should exist after import")
(t/is shape "shape should exist after import")
(t/is (= :rect (:type shape)))
(t/is (mth/close? (:x shape) 0))
(t/is (mth/close? (:y shape) 0))
(t/is (mth/close? (:width shape) 0.01))
(t/is (mth/close? (:height shape) 0.01))))))
(t/deftest export-binfile-v3-compact-round-trip
(let [profile (th/create-profile* 1)
{:keys [file-id page-id-1 shape-id]} (prepare-simple-file-with-ids profile)
output1 (tmp/tempfile :suffix ".zip")
output2 (tmp/tempfile :suffix ".zip")]
(with-redefs [cf/flags (conj cf/flags :binfile-v3-compact)]
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{file-id})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output1)))
;; Verify compact manifest
(let [manifest (v3/get-manifest (str output1))]
(t/is (= 2 (:version manifest))))
(let [result (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input output1)
(v3/import-files!))
imported-id (first result)]
;; Re-export as legacy (without compact flag) and verify
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{imported-id})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output2))
(let [manifest2 (v3/get-manifest (str output2))
result2 (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input output2)
(v3/import-files!))]
(t/is (= 1 (:version manifest2)) "re-export without flag should be legacy")
(t/is (= (count result2) 1))
(t/is (every? uuid? result2))
;; Verify shapes survive the compact -> import -> legacy re-export round-trip
(let [reimported-id (first result2)
reimported-data (-> (bfc/get-file th/*system* reimported-id {:realize? true})
:data)
pages-index (get reimported-data :pages-index)
page (get pages-index page-id-1)
shape (get-in page [:objects shape-id])]
(t/is page "page should exist after round-trip")
(t/is shape "shape should exist after round-trip")
(t/is (= :rect (:type shape)))
(t/is (mth/close? (:x shape) 0))
(t/is (mth/close? (:y shape) 0))
(t/is (mth/close? (:width shape) 0.01))
(t/is (mth/close? (:height shape) 0.01))))))
(t/deftest export-binfile-v3-compact-page-structure
(let [profile (th/create-profile* 1)
{:keys [file-id page-id-1 shape-id]} (prepare-simple-file-with-ids profile)
output (tmp/tempfile :suffix ".zip")]
(with-redefs [cf/flags (conj cf/flags :binfile-v3-compact)]
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{file-id})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output)))
(with-open [^ZipFile zip (ZipFile. (fs/file output))]
(let [entries (iterator-seq (.entries zip))
;; Page entry should exist with embedded objects
page-path (str "files/" file-id "/pages/" page-id-1 ".json")
page-entry (.getEntry zip page-path)]
(t/is page-entry "compact page entry should exist")
;; Per-shape entries should NOT exist in compact format
(doseq [^java.util.zip.ZipEntry entry entries]
(let [name (.getName entry)]
(t/is (not (.startsWith name (str "files/" file-id "/pages/" page-id-1 "/")))
(str "should not have per-shape entries: " name))))
;; Verify page entry contains objects
(with-open [reader (io/reader (.getInputStream zip page-entry))]
(let [page-data (json/read reader :key-fn json/read-kebab-key)]
(t/is (contains? page-data :objects)
"compact page should contain embedded objects")
(t/is (> (count (:objects page-data)) 0)
"compact page objects should not be empty")
(t/is (contains? (:objects page-data) shape-id)
"compact page should contain the shape"))))))))
@@ -0,0 +1,143 @@
;; 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.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)))
+5 -1
View File
@@ -170,7 +170,11 @@
:mcp
:background-blur
:available-viewer-wasm
:stroke-path})
:stroke-path
;; Compact binfile-v3 export: one JSON per page with embedded
;; shapes instead of one JSON per shape.
:binfile-v3-compact})
(def all-flags
(set/union email login varia))
@@ -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}))
+58 -27
View File
@@ -9,6 +9,7 @@
(:require
[app.common.data :as d]
[app.common.files.builder :as fb]
[app.common.files.shape-compact :as fsc]
[app.common.json :as json]
[app.common.media :as media]
[app.common.schema :as sm]
@@ -94,7 +95,7 @@
(-> shape encode-shape json/encode)))
(defn- generate-file-export-procs
[{:keys [id data] :as file}]
[version {:keys [id data] :as file}]
(cons
(let [file (cond-> (select-keys file file-attrs)
(:options data)
@@ -104,7 +105,8 @@
(concat
(let [pages (get data :pages)
pages-index (get data :pages-index)]
pages-index (get data :pages-index)
compact? (= version 2)]
(->> (d/enumerate pages)
(mapcat
@@ -114,14 +116,31 @@
page (-> page
(dissoc :objects)
(assoc :index index))]
(cons
[(str "files/" id "/pages/" page-id ".json")
(delay (-> page encode-page json/encode))]
(map (fn [[shape-id shape]]
(let [shape (assoc shape :page-id page-id)]
(if compact?
(let [compacted-objects
(reduce-kv
(fn [m shape-id shape]
(let [shape (-> shape
(cond-> (or (= (:type shape) :path)
(= (:type shape) :bool))
(update :content vec))
fsc/compact-shape
fsc/round-values
encode-shape)]
(assoc m shape-id shape)))
{}
objects)]
(list
[(str "files/" id "/pages/" page-id ".json")
(delay (-> (assoc page :objects compacted-objects)
json/encode))]))
(cons
[(str "files/" id "/pages/" page-id ".json")
(delay (-> page encode-page json/encode))]
(map (fn [[shape-id shape]]
[(str "files/" id "/pages/" page-id "/" shape-id ".json")
(delay (encode-shape* shape))]))
objects)))))))
(delay (encode-shape* shape))])
objects))))))))
(->> (get data :components)
(map (fn [[component-id component]]
@@ -156,9 +175,9 @@
json/encode))])))))
(defn- generate-files-export-procs
[state]
[state version]
(->> (vals (get state ::fb/files))
(mapcat generate-file-export-procs)))
(mapcat #(generate-file-export-procs version %))))
(defn- generate-media-export-procs
[state]
@@ -182,7 +201,7 @@
(json/encode)))]))))))
(defn- generate-manifest-procs
[state]
[state version]
(let [opts (get state :options)
files (->> (get state ::fb/files)
(mapv (fn [[file-id file]]
@@ -190,7 +209,7 @@
:name (:name file)
:features (:features file)})))
params {:type "penpot/export-files"
:version 1
:version version
:generated-by "penpot-library/%version%"
:referer (get opts :referer)
:files files
@@ -202,11 +221,11 @@
(delay (json/encode params))]))
(defn- generate-procs
[state]
[state version]
(let [state (deref state)]
(cons (generate-manifest-procs state)
(cons (generate-manifest-procs state version)
(concat
(generate-files-export-procs state)
(generate-files-export-procs state version)
(generate-media-export-procs state)))))
(def ^:private
@@ -219,8 +238,10 @@
(constantly nil))
(defn- export
[state writer progress-fn]
(let [procs (into [] xf:add-proc-index (generate-procs state))
[state writer progress-fn version]
(when-not (or (= version 1) (= version 2))
(throw (js/Error. (str "export: invalid version " version ", expected 1 or 2"))))
(let [procs (into [] xf:add-proc-index (generate-procs state version))
total (count procs)]
(->> (p/reduce (fn [writer [path data index]]
(let [data (if (delay? data) (deref data) data)
@@ -238,7 +259,7 @@
(defn export-bytes
([state]
(export state (zip/writer (zip/bytes-writer)) noop-fn))
(export state (zip/writer (zip/bytes-writer)) noop-fn 1))
([state options]
(let [options
(if (object? options)
@@ -246,13 +267,16 @@
options)
progress-fn
(get options :on-progress noop-fn)]
(get options :on-progress noop-fn)
(export state (zip/writer (zip/bytes-writer)) progress-fn))))
version
(get options :version 1)]
(export state (zip/writer (zip/bytes-writer)) progress-fn version))))
(defn export-blob
([state]
(export state (zip/writer (zip/blob-writer)) noop-fn))
(export state (zip/writer (zip/blob-writer)) noop-fn 1))
([state options]
(let [options
(if (object? options)
@@ -260,13 +284,16 @@
options)
progress-fn
(get options :on-progress noop-fn)]
(get options :on-progress noop-fn)
(export state (zip/writer (zip/blob-writer)) progress-fn))))
version
(get options :version 1)]
(export state (zip/writer (zip/blob-writer)) progress-fn version))))
(defn export-stream
([state stream]
(export state (zip/writer stream) noop-fn))
(export state (zip/writer stream) noop-fn 1))
([state stream options]
(let [options
(if (object? options)
@@ -274,5 +301,9 @@
options)
progress-fn
(get options :on-progress noop-fn)]
(export state (zip/writer stream) progress-fn))))
(get options :on-progress noop-fn)
version
(get options :version 1)]
(export state (zip/writer stream) progress-fn version))))
+95
View File
@@ -3,6 +3,7 @@ import test from "node:test";
import * as fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { BlobReader, ZipReader, TextWriter } from "@zip.js/zip.js";
import * as penpot from "#self";
@@ -218,3 +219,97 @@ test("create context with tokens lib as obj", () => {
assert.ok(file.data);
assert.ok(file.data.tokensLib)
});
test("export compact format produces page-level objects", async () => {
const context = penpot.createBuildContext();
const fileId = context.addFile({name: "test file"});
const pageId = context.addPage({name: "test page"});
context.addRect({name: "rect1", x: 100, y: 200, width: 50, height: 30});
const blob = await penpot.exportAsBlob(context, {version: 2});
const zipReader = new ZipReader(new BlobReader(new Blob([blob])));
const entries = await zipReader.getEntries();
const manifestEntry = entries.find(e => e.filename === "manifest.json");
assert.ok(manifestEntry, "manifest should exist");
const manifest = JSON.parse(await manifestEntry.getData(new TextWriter()));
assert.equal(manifest.version, 2);
const pageEntry = entries.find(e =>
e.filename === `files/${fileId}/pages/${pageId}.json`);
assert.ok(pageEntry, "page entry should exist");
const pageData = JSON.parse(await pageEntry.getData(new TextWriter()));
assert.ok(pageData.objects, "page should contain objects");
assert.ok(Object.keys(pageData.objects).length > 0,
"objects should not be empty");
const shapeEntries = entries.filter(e =>
e.filename.startsWith(`files/${fileId}/pages/${pageId}/`) &&
e.filename !== `files/${fileId}/pages/${pageId}.json`);
assert.equal(shapeEntries.length, 0, "should not have per-shape entries");
await zipReader.close();
});
test("export legacy format produces per-shape entries", async () => {
const context = penpot.createBuildContext();
const fileId = context.addFile({name: "test file"});
const pageId = context.addPage({name: "test page"});
context.addRect({name: "rect1", x: 100, y: 200, width: 50, height: 30});
const blob = await penpot.exportAsBlob(context, {version: 1});
const zipReader = new ZipReader(new BlobReader(new Blob([blob])));
const entries = await zipReader.getEntries();
const manifestEntry = entries.find(e => e.filename === "manifest.json");
assert.ok(manifestEntry, "manifest should exist");
const manifest = JSON.parse(await manifestEntry.getData(new TextWriter()));
assert.equal(manifest.version, 1);
const pageEntry = entries.find(e =>
e.filename === `files/${fileId}/pages/${pageId}.json`);
assert.ok(pageEntry, "page entry should exist");
const pageData = JSON.parse(await pageEntry.getData(new TextWriter()));
assert.ok(!pageData.objects, "legacy page should not contain objects");
const shapeEntries = entries.filter(e =>
e.filename.startsWith(`files/${fileId}/pages/${pageId}/`) &&
e.filename !== `files/${fileId}/pages/${pageId}.json`);
assert.ok(shapeEntries.length > 0, "should have per-shape entries");
await zipReader.close();
});
test("export default format produces per-shape entries", async () => {
const context = penpot.createBuildContext();
const fileId = context.addFile({name: "test file"});
const pageId = context.addPage({name: "test page"});
context.addRect({name: "rect1", x: 100, y: 200, width: 50, height: 30});
const blob = await penpot.exportAsBlob(context);
const zipReader = new ZipReader(new BlobReader(new Blob([blob])));
const entries = await zipReader.getEntries();
const manifest = JSON.parse(
await entries.find(e => e.filename === "manifest.json")
.getData(new TextWriter()));
assert.equal(manifest.version, 1);
const shapeEntries = entries.filter(e =>
e.filename.startsWith(`files/${fileId}/pages/${pageId}/`) &&
e.filename !== `files/${fileId}/pages/${pageId}.json`);
assert.ok(shapeEntries.length > 0,
"default (legacy) should have per-shape entries");
await zipReader.close();
});