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
This commit is contained in:
Andrey Antukh 2026-07-16 12:36:18 +00:00
parent b59b259a77
commit 54c692d10c
3 changed files with 125 additions and 28 deletions

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))
@ -55,6 +57,7 @@
[:map {:title "Manifest"}
[:version ::sm/int]
[:type :string]
[:format {:optional true} [:enum "compact" "legacy"]]
[:referer {:optional true} :string]
[:generated-by {:optional true} :string]
@ -63,7 +66,8 @@
[:map
[:id ::sm/uuid]
[:name :string]
[:features ::cfeat/features]]]]
[:features ::cfeat/features]
[:format {:optional true} [:enum "compact" "legacy"]]]]]
[:relations {:optional true}
[:vector
@ -217,6 +221,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]
@ -286,7 +300,8 @@
(vswap! bfc/*state* update :files assoc file-id
{:id file-id
:name (:name file)
:features (:features file)})
:features (:features file)
:format (if (contains? cf/flags :binfile-v3-compact) "compact" "legacy")})
(let [file (cond-> (select-keys file bfc/file-attrs)
(:options data)
@ -302,22 +317,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 (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)
@ -371,11 +393,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))
@ -389,7 +412,8 @@
;; Write manifest file
(let [files (:files @bfc/*state*)
params {:type "penpot/export-files"
:version 1
:version (if compact? 2 1)
:format (if compact? "compact" "legacy")
:generated-by (str "penpot/" (:full cf/version))
:refer "penpot"
:files (vec (vals files))
@ -685,7 +709,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)
@ -693,8 +717,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)))
@ -936,10 +969,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))

View File

@ -14,6 +14,7 @@
[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]
@ -105,3 +106,60 @@
(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)
file (prepare-simple-file profile)
output (tmp/tempfile :suffix ".zip")]
(with-redefs [cf/flags (conj cf/flags :binfile-v3-compact)]
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{(:id file)})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output)))
(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)))))
(t/deftest export-binfile-v3-compact-round-trip
(let [profile (th/create-profile* 1)
file (prepare-simple-file 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 #{(:id file)})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output1)))
(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)]
(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 [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 (= (count result2) 1))
(t/is (every? uuid? result2))))))

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