Compare commits

...
Author SHA1 Message Date
Andrey Antukh 16d0680ecf Bound binfile import db timeout and zip entry scanning
Binfile import ran the whole import in a single transaction with
idle_in_transaction_session_timeout disabled (= 0), so a stalled
import could retain a connection pool slot indefinitely. Set a
finite 20 minutes ceiling via SET LOCAL instead (a compile-time
constant interpolated into the SQL; PostgreSQL does not accept bind
parameters on SET).

The v3 importer also located each file data by rescanning the full
zip entry collection once per manifest file and once per page,
making the cost close to quadratic on large files. Replace the
per-file regex matchers with a single classification pass that
groups entries by their raw path shape; consumers now lookup their
entries per file and page. As a deliberate tightening, the .json
suffix is matched literally: the previous regexes left the dot
unescaped, so crafted paths like files/<f>/tokensXjson or
objects/x-json matched by accident and are now ignored.

Closes #11579

AI-assisted-by: omen-alpha
2026-09-09 15:19:30 +02:00
Danny ShirelyandAndrey Antukh d45c6710b7 🎉 Implement independent image bounds resizing (#11430)
* 🎉 Implement independent image bounds resizing

Add canvas resize interaction mode that allows users to resize an
image object's bounding box independently from the underlying bitmap
content without scaling or distortion while holding the Mod key.

AI-assisted-by: gemini-2.5-pro

* ♻️ Address reviewer feedback from elenatorro

- Remove legacy cfh/image-shape? check in shape-has-image-fill?
- Guard bounds-resize with positive dimensions instead of clamping scalev to preserve flipping
- Remove :metadata from transform-attrs in modifiers.cljs
- Restore preserveAspectRatio logic based on keep-ar? in fills.cljs
- Compute source rect against destination rect for raster and SVG fills in WASM renderer

* 🔧 Fix clippy needless borrow warnings in wasm image fills

---------

Co-authored-by: Andrey Antukh <niwi@niwi.nz>
2026-09-09 15:16:56 +02:00
15 changed files with 1007 additions and 258 deletions

No files matched your search

+8 -2
View File
@@ -647,10 +647,16 @@
data
library-ids)))
(defn disable-database-timeouts!
(def ^:const import-transaction-timeout-ms
"Ceiling for binfile import transactions (20 minutes). Interpolated
directly into SQL: compile-time constant, never user input."
(* 20 60 1000))
(defn configure-database-timeouts!
[cfg]
(let [conn (db/get-connection cfg)]
(db/exec-one! conn ["SET LOCAL idle_in_transaction_session_timeout = 0"])
(db/exec-one! conn [(str "SET LOCAL idle_in_transaction_session_timeout = "
import-transaction-timeout-ms)])
(db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])))
(defn process-file
+1 -1
View File
@@ -454,7 +454,7 @@
(defn- read-import-v1
[{:keys [::db/conn ::bfc/project-id ::bfc/profile-id ::bfc/input] :as cfg}]
(bfc/disable-database-timeouts! cfg)
(bfc/configure-database-timeouts! cfg)
(pu/with-open [input (zstd-input-stream input)
input (io/data-input-stream input)]
+92 -96
View File
@@ -529,89 +529,85 @@
(let [manifest (json/read reader :key-fn json/read-kebab-key)]
(decode-manifest manifest)))))
(defn- match-media-entry-fn
[file-id]
(let [pattern (str "^files/" file-id "/media/([^/]+).json$")
pattern (re-pattern pattern)]
(fn [entry]
(when-let [[_ id] (re-matches pattern (zip-entry-name entry))]
{:entry entry
:id (parse-uuid id)}))))
(def ^:private file-object-entry-categories
"Zip path segments that hold per-file object entries on the
`files/<file-id>/<category>/<object-id>.json` shape."
#{"media" "colors" "components" "typographies"})
(defn- match-color-entry-fn
[file-id]
(let [pattern (str "^files/" file-id "/colors/([^/]+).json$")
pattern (re-pattern pattern)]
(fn [entry]
(when-let [[_ id] (re-matches pattern (zip-entry-name entry))]
{:entry entry
:id (parse-uuid id)}))))
(defn- index-entry-name
"Classify a single zip entry by the raw shape of its path and
accumulate it on the index.
(defn- match-component-entry-fn
[file-id]
(let [pattern (str "^files/" file-id "/components/([^/]+).json$")
pattern (re-pattern pattern)]
(fn [entry]
(when-let [[_ id] (re-matches pattern (zip-entry-name entry))]
{:entry entry
:id (parse-uuid id)}))))
It replaces the per-file regex matchers with a single
classification pass over all entries. The `.json` suffix is matched
literally: the previous regexes left the dot unescaped, so crafted
paths like `files/<file-id>/tokensXjson` or `objects/x-json` matched
by accident; requiring the literal suffix ignores them (legitimate
exports always write a literal `.json` suffix). Unknown paths are
ignored."
[index ^String name entry]
(if-not (and name (str/ends-with? name ".json"))
index
(let [base (subs name 0 (- (count name) 5))
segs (str/split base "/")
seg-n (count segs)
seg-1 (nth segs 0 nil)
seg-2 (nth segs 1 nil)
seg-3 (nth segs 2 nil)]
(defn- match-typography-entry-fn
[file-id]
(let [pattern (str "^files/" file-id "/typographies/([^/]+).json$")
pattern (re-pattern pattern)]
(fn [entry]
(when-let [[_ id] (re-matches pattern (zip-entry-name entry))]
{:entry entry
:id (parse-uuid id)}))))
(if-not (and (pos? seg-n) (every? #(pos? (count %)) segs))
index
(cond
;; objects/<object-id>.json
(and (= seg-n 2) (= seg-1 "objects"))
(update index :objects bfc/conj-vec
{:entry entry :id (parse-uuid seg-2)})
(defn- match-tokens-lib-entry-fn
[file-id]
(let [pattern (str "^files/" file-id "/tokens.json$")
pattern (re-pattern pattern)]
(fn [entry]
(when-let [[_] (re-matches pattern (zip-entry-name entry))]
{:entry entry}))))
;; files/<file-id>/tokens.json
(and (= seg-n 3) (= seg-1 "files") (= seg-3 "tokens"))
(update-in index [:tokens seg-2] bfc/conj-vec {:entry entry})
(defn- match-thumbnail-entry-fn
[file-id]
(let [pattern (str "^files/" file-id "/thumbnails/([^/]+)/([^/]+)/([^/]+).json$")
pattern (re-pattern pattern)]
(fn [entry]
(when-let [[_ tag page-id frame-id] (re-matches pattern (zip-entry-name entry))]
{:entry entry
:tag tag
:page-id (parse-uuid page-id)
:frame-id (parse-uuid frame-id)
:file-id file-id}))))
;; files/<file-id>/thumbnails/<tag>/<page-id>/<frame-id>.json
(and (= seg-n 6) (= seg-1 "files") (= seg-3 "thumbnails"))
(update-in index [:thumbnails seg-2] bfc/conj-vec
{:entry entry
:tag (nth segs 3)
:page-id (parse-uuid (nth segs 4))
:frame-id (parse-uuid (nth segs 5))
:file-id seg-2})
(defn- match-page-entry-fn
[file-id]
(let [pattern (str "^files/" file-id "/pages/([^/]+).json$")
pattern (re-pattern pattern)]
(fn [entry]
(when-let [[_ id] (re-matches pattern (zip-entry-name entry))]
{:entry entry
:id (parse-uuid id)}))))
;; files/<file-id>/pages/<page-id>.json
(and (= seg-n 4) (= seg-1 "files") (= seg-3 "pages"))
(update-in index [:pages seg-2] bfc/conj-vec
{:entry entry :id (parse-uuid (nth segs 3))})
(defn- match-shape-entry-fn
[file-id page-id]
(let [pattern (str "^files/" file-id "/pages/" page-id "/([^/]+).json$")
pattern (re-pattern pattern)]
(fn [entry]
(when-let [[_ id] (re-matches pattern (zip-entry-name entry))]
{:entry entry
:page-id page-id
:id (parse-uuid id)}))))
;; files/<file-id>/pages/<page-id>/<shape-id>.json
(and (= seg-n 5) (= seg-1 "files") (= seg-3 "pages"))
(update-in index [:shapes seg-2 (nth segs 3)] bfc/conj-vec
{:entry entry
:page-id (nth segs 3)
:id (parse-uuid (nth segs 4))})
(defn- match-storage-entry-fn
[]
(let [pattern "^objects/([^/]+).json$"
pattern (re-pattern pattern)]
(fn [entry]
(when-let [[_ id] (re-matches pattern (zip-entry-name entry))]
{:entry entry
:id (parse-uuid id)}))))
;; files/<file-id>/<category>/<object-id>.json
(and (= seg-n 4)
(= seg-1 "files")
(contains? file-object-entry-categories seg-3))
(update-in index [(keyword seg-3) seg-2] bfc/conj-vec
{:entry entry :id (parse-uuid (nth segs 3))})
:else
index)))))
(defn- index-entries
"Classify all the provided zip entries in a single pass and group
them by their path shape, so import consumers can lookup their
entries per file (and per page) instead of rescanning the whole
entry collection for every file and page."
[entries]
(reduce (fn [index entry]
(index-entry-name index (zip-entry-name entry) entry))
{}
entries))
(defn- read-entry
[^ZipFile input entry]
@@ -644,8 +640,8 @@
(validate-plugin-data))))
(defn- read-file-media
[{:keys [::bfc/input ::entries]} file-id]
(->> (keep (match-media-entry-fn file-id) entries)
[{:keys [::bfc/input ::entries-index]} file-id]
(->> (get-in entries-index [:media (str file-id)])
(reduce (fn [result {:keys [id entry]}]
(let [object (->> (read-entry input entry)
(decode-media)
@@ -664,8 +660,8 @@
(not-empty)))
(defn- read-file-colors
[{:keys [::bfc/input ::entries]} file-id]
(->> (keep (match-color-entry-fn file-id) entries)
[{:keys [::bfc/input ::entries-index]} file-id]
(->> (get-in entries-index [:colors (str file-id)])
(reduce (fn [result {:keys [id entry]}]
(let [object (->> (read-entry input entry)
(decode-color)
@@ -678,7 +674,7 @@
(not-empty)))
(defn- read-file-components
[{:keys [::bfc/input ::entries]} file-id]
[{:keys [::bfc/input ::entries-index]} file-id]
(let [clean-component-post-decode
(fn [component]
(d/update-when component :objects
@@ -696,7 +692,7 @@
objects
objects))))]
(->> (keep (match-component-entry-fn file-id) entries)
(->> (get-in entries-index [:components (str file-id)])
(reduce (fn [result {:keys [id entry]}]
(let [object (->> (read-entry input entry)
(clean-component-pre-decode)
@@ -710,8 +706,8 @@
(not-empty))))
(defn- read-file-typographies
[{:keys [::bfc/input ::entries]} file-id]
(->> (keep (match-typography-entry-fn file-id) entries)
[{:keys [::bfc/input ::entries-index]} file-id]
(->> (get-in entries-index [:typographies (str file-id)])
(reduce (fn [result {:keys [id entry]}]
(let [object (->> (read-entry input entry)
(decode-typography)
@@ -724,16 +720,16 @@
(not-empty)))
(defn- read-file-tokens-lib
[{:keys [::bfc/input ::entries]} file-id]
(when-let [entry (d/seek (match-tokens-lib-entry-fn file-id) entries)]
[{:keys [::bfc/input ::entries-index]} file-id]
(when-let [{:keys [entry]} (first (get-in entries-index [:tokens (str file-id)]))]
(events/tap :progress {:section :tokens-lib :file-id file-id})
(->> (read-plain-entry input entry)
(decode-tokens-lib)
(validate-tokens-lib))))
(defn- read-file-shapes
[{:keys [::bfc/input ::entries] :as cfg} file-id page-id]
(->> (keep (match-shape-entry-fn file-id page-id) entries)
[{:keys [::bfc/input ::entries-index] :as cfg} file-id page-id]
(->> (get-in entries-index [:shapes (str file-id) (str page-id)])
(reduce (fn [result {:keys [id entry]}]
(let [object (->> (read-entry input entry)
(bfl/clean-shape-pre-decode)
@@ -746,8 +742,8 @@
(not-empty)))
(defn- read-file-pages
[{:keys [::bfc/input ::entries] :as cfg} file-id]
(->> (keep (match-page-entry-fn file-id) entries)
[{:keys [::bfc/input ::entries-index] :as cfg} file-id]
(->> (get-in entries-index [:pages (str file-id)])
(keep (fn [{:keys [id entry]}]
(let [page (->> (read-entry input entry)
(decode-page))
@@ -762,8 +758,8 @@
(d/ordered-map))))
(defn- read-file-thumbnails
[{:keys [::bfc/input ::entries] :as cfg} file-id]
(->> (keep (match-thumbnail-entry-fn file-id) entries)
[{:keys [::bfc/input ::entries-index] :as cfg} file-id]
(->> (get-in entries-index [:thumbnails (str file-id)])
(reduce (fn [result {:keys [page-id frame-id tag entry]}]
(let [object (->> (read-entry input entry)
(decode-file-thumbnail)
@@ -892,7 +888,7 @@
(bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp)))))))
(defn- import-storage-objects
[{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}]
[{:keys [::bfc/input ::entries-index ::bfc/timestamp] :as cfg}]
(events/tap :progress {:section :storage-objects})
;; IMPORTANT: we strongly do not reuse the main connection that can
@@ -903,7 +899,7 @@
;; what the storage subsystem registers in other parallel
;; transaction
(let [storage (sto/resolve cfg)
entries (keep (match-storage-entry-fn) entries)]
entries (:objects entries-index)]
(doseq [{:keys [id entry]} entries]
(let [object (-> (read-entry input entry)
@@ -1058,7 +1054,7 @@
(defn- import-files*
[{:keys [::manifest] :as cfg}]
(bfc/disable-database-timeouts! cfg)
(bfc/configure-database-timeouts! cfg)
(vswap! bfc/*state* update :index bfc/update-index (:files manifest) :id)
@@ -1126,7 +1122,7 @@
:hint "unable to perform in-place update with binfile containing more than 1 file"
:manifest manifest))
(bfc/disable-database-timeouts! cfg)
(bfc/configure-database-timeouts! cfg)
(let [ref-file (bfc/get-minimal-file cfg file-id ::db/for-update true)
file (first (get manifest :files))
@@ -1165,7 +1161,7 @@
:found (count entries))))
cfg (-> cfg
(assoc ::entries entries)
(assoc ::entries-index (index-entries entries))
(assoc ::manifest manifest)
(assoc ::bfc/timestamp timestamp))]
+338 -87
View File
@@ -17,6 +17,7 @@
[app.common.thumbnails :as thc]
[app.common.time :as ct]
[app.common.types.shape :as cts]
[app.common.types.tokens-lib :as ctob]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.db :as db]
@@ -55,46 +56,47 @@
(:result out)))
(defn- prepare-simple-file
[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}])
([profile] (prepare-simple-file profile 1))
([profile idx]
(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* idx {: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})}])
(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})}])
(dissoc file :data)))
(dissoc file :data))))
(def ^:private svg-raw-page-id (uuid/custom 1 1))
(def ^:private svg-raw-root-id (uuid/custom 3 1))
@@ -103,58 +105,59 @@
(defn- prepare-svg-raw-file
"A file containing an svg-raw subtree (an svg-raw parent with an
svg-raw child), which is what importing an SVG produces."
[profile]
(let [page-id svg-raw-page-id
root-id svg-raw-root-id
child-id svg-raw-child-id
([profile] (prepare-svg-raw-file profile 1))
([profile idx]
(let [page-id svg-raw-page-id
root-id svg-raw-root-id
child-id svg-raw-child-id
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 "page 1"
:id page-id}])
file (th/create-file* idx {: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 "page 1"
:id page-id}])
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
:id root-id
:parent-id uuid/zero
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id root-id
:name "svg-root"
:frame-id uuid/zero
:parent-id uuid/zero
:type :svg-raw
:content {:tag :svg :attrs {} :content []}})}
{:type :add-obj
:page-id page-id
:id child-id
:parent-id root-id
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id child-id
:name "svg-text"
:frame-id uuid/zero
:parent-id root-id
:type :svg-raw
:content {:tag :text :attrs {} :content []}})}])
(update-file!
:file-id (:id file)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-obj
:page-id page-id
:id root-id
:parent-id uuid/zero
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id root-id
:name "svg-root"
:frame-id uuid/zero
:parent-id uuid/zero
:type :svg-raw
:content {:tag :svg :attrs {} :content []}})}
{:type :add-obj
:page-id page-id
:id child-id
:parent-id root-id
:frame-id uuid/zero
:components-v2 true
:obj (cts/setup-shape
{:id child-id
:name "svg-text"
:frame-id uuid/zero
:parent-id root-id
:type :svg-raw
:content {:tag :text :attrs {} :content []}})}])
(dissoc file :data)))
(dissoc file :data))))
(t/deftest import-binfile-v3-preserves-svg-raw-children
(let [profile (th/create-profile* 1)
@@ -1967,3 +1970,251 @@
d)))]
(t/is (= :validation (:type out)))
(t/is (= :max-file-size-reached (:code out))))))
(t/deftest import-configures-finite-transaction-idle-timeout
;; The binfile import transaction must set a finite ceiling for
;; idle_in_transaction_session_timeout (20 min) instead of disabling
;; it entirely: a stalled import must not retain a pool connection
;; without any upper bound. The ceiling must be scoped with SET
;; LOCAL: once the transaction ends, the pool session default must
;; be restored for subsequent transactions.
(let [pg-setting-sql ["SELECT setting FROM pg_settings WHERE name = 'idle_in_transaction_session_timeout'"]
in-tx (db/tx-run! th/*system*
(fn [cfg]
(bfc/configure-database-timeouts! cfg)
(:setting (db/exec-one! cfg pg-setting-sql))))
;; SET LOCAL must not leak past the transaction boundary
after (db/tx-run! th/*system*
(fn [cfg]
(:setting (db/exec-one! cfg pg-setting-sql))))]
(t/is (= "1200000" in-tx))
(t/is (= "300000" after))))
(def ^:private index-test-file-id "22222222-2222-2222-2222-222222222222")
(def ^:private index-test-page-id "44444444-4444-4444-4444-444444444444")
(defn- index-entries-of
[names]
(@#'v3/index-entries (map #(java.util.zip.ZipEntry. %) names)))
(t/deftest index-entries-classifies-entry-names
(let [f index-test-file-id
page index-test-page-id
index (index-entries-of
["manifest.json"
(str "files/" f ".json")
(str "files/" f "/plugin-data.json")
(str "files/" f "/tokens.json")
"objects/11111111-1111-1111-1111-111111111111.json"
(str "files/" f "/media/33333333-3333-3333-3333-333333333333.json")
(str "files/" f "/colors/not-a-uuid.json")
(str "files/" f "/components/comp.json")
(str "files/" f "/typographies/t.json")
(str "files/" f "/pages/" page ".json")
(str "files/" f "/pages/" page "/shape.json")
(str "files/" f "/thumbnails/medium/" page "/frame.json")])]
(t/is (= [(parse-uuid "11111111-1111-1111-1111-111111111111")]
(mapv :id (:objects index))))
(t/is (= #{f} (set (keys (:media index)))))
(t/is (= [(parse-uuid "33333333-3333-3333-3333-333333333333")]
(mapv :id (get (:media index) f))))
(t/is (= #{f} (set (keys (:colors index)))))
;; non-uuid ids are preserved as nil, same as the matchers do today
(t/is (= [nil] (mapv :id (get (:colors index) f))))
(t/is (= #{f} (set (keys (:components index)))))
(t/is (= #{f} (set (keys (:typographies index)))))
(t/is (= #{f} (set (keys (:pages index)))))
(t/is (= [(parse-uuid page)] (mapv :id (get (:pages index) f))))
(t/is (= #{f} (set (keys (:shapes index)))))
(t/is (= #{page} (set (keys (get (:shapes index) f)))))
(t/is (= [nil] (mapv :id (get (get (:shapes index) f) page))))
(t/is (= #{f} (set (keys (:thumbnails index)))))
(let [thumb (first (get (:thumbnails index) f))]
(t/is (= "medium" (:tag thumb)))
(t/is (= (parse-uuid page) (:page-id thumb)))
(t/is (= (parse-uuid "frame") (:frame-id thumb)))
(t/is (= f (:file-id thumb))))
(t/is (= #{f} (set (keys (:tokens index)))))))
(t/deftest index-entries-ignores-unknown-entry-names
;; unknown paths, wrong depth, wrong suffix or non-uuid file segments
;; are all ignored, same as today's anchored regexes
(t/is (empty? (index-entries-of ["manifest.json"])))
(t/is (empty? (index-entries-of [(str "files/" index-test-file-id ".json")])))
(t/is (empty? (index-entries-of [(str "files/" index-test-file-id "/plugin-data.json")])))
(t/is (empty? (index-entries-of ["objects/a/b.json"])))
(t/is (empty? (index-entries-of [(str "files/" index-test-file-id "/media/a/b.json")])))
(t/is (empty? (index-entries-of [(str "files/" index-test-file-id "/media/a.txt")])))
(t/is (empty? (index-entries-of [(str "files/" index-test-file-id "/unknown/a.json")])))
(t/is (empty? (index-entries-of [(str "files//media/a.json")]))))
(t/deftest index-entries-requires-literal-json-suffix
;; deliberate tightening vs today's regexes: the dot is unescaped in
;; `([^/]+).json$` / `tokens.json$`, so today `tokensXjson` and
;; `objects/x-json` DO match; the classifier requires a literal
;; `.json` suffix and ignores them
(t/is (empty? (index-entries-of [(str "files/" index-test-file-id "/tokensXjson")])))
(t/is (empty? (index-entries-of ["objects/x-json"]))))
(t/deftest import-binfile-v3-multiple-files-preserves-per-file-content
;; the entries index must attribute every zip entry to the file it
;; belongs to: with several files in the same manifest, pages and
;; shapes of one file must not leak into another. The file also
;; carries library content (color, typography, component and tokens
;; set) so every rewired consumer is exercised on its present path.
(let [profile (th/create-profile* 1)
simple (prepare-simple-file profile)
color-id (uuid/custom 5 1)
typo-id (uuid/custom 5 2)
comp-id (uuid/custom 5 3)
svg (prepare-svg-raw-file profile 2)
output (tmp/tempfile :suffix ".zip")]
(update-file!
:file-id (:id simple)
:profile-id (:id profile)
:revn 0
:vern 0
:changes
[{:type :add-color
:color {:id color-id
:name "import-color"
:color "#FF0000"}}
{:type :add-typography
:typography {:id typo-id
:name "import-typography"
:font-id "source-sans-pro"
:font-family "Source Sans Pro"
:font-variant-id "regular"
:font-size "16"
:font-weight "400"
:font-style "normal"
:line-height "1.4"
:letter-spacing "0"
:text-transform "none"}}
{:type :add-component
:id comp-id
:name "import-component"
:path ""
:main-instance-id (uuid/custom 2 1)
:main-instance-page (uuid/custom 1 1)}
{:type :set-tokens-lib
:tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "ImportSet")))}])
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{(:id simple) (:id svg)})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output))
;; import returns the imported file ids plus the library link
;; resolution; the test only needs the ids
(let [result (:file-ids (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input output)
(v3/import-files!)))
files (map #(bfc/get-file th/*system* %) result)
svg-imported (some #(when (contains? (get-in % [:data :pages-index
svg-raw-page-id
:objects])
svg-raw-root-id)
%)
files)
simple-imported (some #(when (contains? (get-in % [:data :pages-index
(uuid/custom 1 1)
:objects])
(uuid/custom 2 1))
%)
files)]
(t/is (= 2 (count result)))
(t/is (= 2 (count (distinct result))))
(t/is (some? svg-imported))
(t/is (some? simple-imported))
;; the svg-raw file keeps its subtree on its own page (plus the
;; default page created by create-file)
(t/is (= [svg-raw-child-id]
(get-in svg-imported [:data :pages-index svg-raw-page-id
:objects svg-raw-root-id :shapes])))
(t/is (= 2 (count (get-in svg-imported [:data :pages-index]))))
;; the simple file keeps its default page plus its two pages and
;; its shape
(t/is (= 3 (count (get-in simple-imported [:data :pages-index]))))
(t/is (contains? (get-in simple-imported
[:data :pages-index (uuid/custom 1 1) :objects])
(uuid/custom 2 1)))
;; library content is restored on its present path: the
;; consumers' get-in keys must match the classifier buckets
(t/is (some? (get-in simple-imported [:data :colors color-id])))
(t/is (some? (get-in simple-imported [:data :typographies typo-id])))
(t/is (some? (get-in simple-imported [:data :components comp-id])))
(t/is (= ["ImportSet"]
(vec (ctob/get-set-names
(get-in simple-imported [:data :tokens-lib]))))))))
(t/deftest import-binfile-v3-restores-media-objects
;; storage objects, per-file media entries and object thumbnails
;; are classified through the entries index and restored end to end
(let [profile (th/create-profile* 1)
file (prepare-file-with-media profile)
thumb-page-id (uuid/custom 1 1)
thumb-frame-id (uuid/custom 6 1)
thumb-tag "medium"
output (tmp/tempfile :suffix ".zip")]
;; a thumbnail row backed by the same storage object as the media
;; object, so the export produces a thumbnails/ zip entry
(let [mobj (th/db-get :file-media-object {:file-id (:id file)})]
(db/insert! th/*system* :file-tagged-object-thumbnail
{:file-id (:id file)
:tag thumb-tag
:object-id (thc/fmt-object-id {:file-id (:id file)
:page-id thumb-page-id
:frame-id thumb-frame-id
:tag thumb-tag})
:media-id (:media-id mobj)}))
(v3/export-files!
(-> th/*system*
(assoc ::bfc/ids #{(:id file)})
(assoc ::bfc/embed-assets false)
(assoc ::bfc/include-libraries false))
(io/output-stream output))
;; import returns the imported file ids plus the library link
;; resolution; the test only needs the ids
(let [result (:file-ids (-> th/*system*
(assoc ::bfc/project-id (:default-project-id profile))
(assoc ::bfc/profile-id (:id profile))
(assoc ::bfc/input output)
(v3/import-files!)))
mobjs (db/query th/*system* :file-media-object
{:file-id (first result)})
thumbs (db/query th/*system* :file-tagged-object-thumbnail
{:file-id (first result)})]
(t/is (= 1 (count result)))
(t/is (pos? (count mobjs)))
(t/is (every? some? (map :media-id mobjs)))
;; the thumbnail is restored with its object-id rebuilt around
;; the new file id and a media-id that resolves to storage
(t/is (= 1 (count thumbs)))
(let [thumb (first thumbs)]
(t/is (= thumb-tag (:tag thumb)))
(t/is (= (str (first result) "/" thumb-page-id "/" thumb-frame-id "/" thumb-tag)
(:object-id thumb)))
(t/is (some? (:media-id thumb)))))))
+9 -1
View File
@@ -72,6 +72,13 @@
[:map {:title "PlainColorAttrs"}
[:color schema:hex-color]])
(def schema:image-transform
[:map {:title "ImageTransform" :closed true}
[:x {:optional true} ::sm/safe-number]
[:y {:optional true} ::sm/safe-number]
[:width {:optional true} ::sm/safe-number]
[:height {:optional true} ::sm/safe-number]])
(def schema:image
[:map {:title "ImageColor" :closed true}
[:width [::sm/int {:min 0 :gen/gen sg/int}]]
@@ -79,7 +86,8 @@
[:mtype {:gen/gen (sg/elements cm/image-types)} ::sm/text]
[:id ::sm/uuid]
[:name {:optional true} ::sm/text]
[:keep-aspect-ratio {:optional true} :boolean]])
[:keep-aspect-ratio {:optional true} :boolean]
[:transform {:optional true} schema:image-transform]])
(def image-attrs
"A set of attrs that corresponds to image data type"
+49 -27
View File
@@ -119,12 +119,15 @@
(defn write-image-fill
[offset buffer opacity image]
(let [image-id (get image :id)
image-width (get image :width)
image-height (get image :height)
alpha (mth/floor (* opacity 0xff))
keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
flags (bit-or keep-aspect-ratio 0x00)]
(let [image-id (get image :id)
image-width (get image :width)
image-height (get image :height)
alpha (mth/floor (* opacity 0xff))
keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
transform (get image :transform)
has-transform? (some? transform)
transform-flag (if has-transform? 0x02 0x00)
flags (bit-or keep-aspect-ratio transform-flag)]
(buf/write-byte buffer (+ offset 0) 0x03)
(buf/write-uuid buffer (+ offset 4) image-id)
(buf/write-byte buffer (+ offset 20) alpha)
@@ -132,6 +135,17 @@
(buf/write-short buffer (+ offset 22) 0) ;; 2-byte padding (reserved for future use)
(buf/write-int buffer (+ offset 24) image-width)
(buf/write-int buffer (+ offset 28) image-height)
(if has-transform?
(do
(buf/write-float buffer (+ offset 32) (double (get transform :x 0.0)))
(buf/write-float buffer (+ offset 36) (double (get transform :y 0.0)))
(buf/write-float buffer (+ offset 40) (double (get transform :width 1.0)))
(buf/write-float buffer (+ offset 44) (double (get transform :height 1.0))))
(do
(buf/write-float buffer (+ offset 32) 0.0)
(buf/write-float buffer (+ offset 36) 0.0)
(buf/write-float buffer (+ offset 40) 1.0)
(buf/write-float buffer (+ offset 44) 1.0)))
(+ offset FILL-U8-SIZE)))
(defn- write-metadata
@@ -208,28 +222,36 @@
:type type}})
3 ;; image fill
(let [id (buf/read-uuid dbuffer (+ doffset 4))
alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
opacity (mth/precision (/ alpha 0xff) 2)
flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
ratio (boolean (bit-and flags 0x01))
width (buf/read-int dbuffer (+ doffset 24))
height (buf/read-int dbuffer (+ doffset 28))
mtype (buf/read-short mbuffer (+ moffset 2))
mtype (case mtype
0x01 "image/jpeg"
0x02 "image/png"
0x03 "image/gif"
0x04 "image/webp"
0x05 "image/svg+xml")]
(let [id (buf/read-uuid dbuffer (+ doffset 4))
alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
opacity (mth/precision (/ alpha 0xff) 2)
flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
ratio (not (zero? (bit-and flags 0x01)))
has-tf (not (zero? (bit-and flags 0x02)))
width (buf/read-int dbuffer (+ doffset 24))
height (buf/read-int dbuffer (+ doffset 28))
transform (when has-tf
{:x (buf/read-float dbuffer (+ doffset 32))
:y (buf/read-float dbuffer (+ doffset 36))
:width (buf/read-float dbuffer (+ doffset 40))
:height (buf/read-float dbuffer (+ doffset 44))})
mtype (buf/read-short mbuffer (+ moffset 2))
mtype (case mtype
0x01 "image/jpeg"
0x02 "image/png"
0x03 "image/gif"
0x04 "image/webp"
0x05 "image/svg+xml")]
{:fill-opacity opacity
:fill-image {:id id
:width width
:height height
:mtype mtype
:keep-aspect-ratio ratio
;; FIXME: we are not encodign the name, looks useless
:name "sample"}}))]
:fill-image (cond-> {:id id
:width width
:height height
:mtype mtype
:keep-aspect-ratio ratio
;; FIXME: we are not encodign the name, looks useless
:name "sample"}
(some? transform)
(assoc :transform transform))}))]
(if refs?
(let [ref-file (buf/read-uuid mbuffer (+ moffset 4))
@@ -0,0 +1,275 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-image-bounds-resize-test
(:require
#?(:clj [clojure.test :refer [deftest is testing]]
:cljs [cljs.test :refer-macros [deftest is testing]])
[app.common.math :as mth]
[app.common.schema :as sm]
[app.common.types.color :as clr]
[app.common.types.fills :as fills]
[app.common.types.fills.impl :as fills.impl]
[app.common.uuid :as uuid]))
(deftest test-image-transform-schema
(testing "validates image with transform"
(let [img {:id (uuid/custom 1)
:width 400
:height 300
:mtype "image/png"
:keep-aspect-ratio true
:transform {:x 0.1 :y -0.2 :width 1.5 :height 2.0}}]
(is (sm/validate clr/schema:image img))))
(testing "validates image without transform"
(let [img {:id (uuid/custom 1)
:width 400
:height 300
:mtype "image/png"
:keep-aspect-ratio true}]
(is (sm/validate clr/schema:image img))))
(testing "validates fill with image transform"
(let [fill {:fill-opacity 0.8
:fill-image {:id (uuid/custom 1)
:width 400
:height 300
:mtype "image/png"
:keep-aspect-ratio true
:transform {:x -0.5 :y -0.5 :width 2.0 :height 2.0}}}]
(is (sm/validate fills/schema:fill fill)))))
(deftest test-image-fill-buffer-roundtrip
(testing "roundtrip image fill without transform"
(let [fill-vec [{:fill-opacity 0.9
:fill-image {:id (uuid/custom 1)
:width 800
:height 600
:mtype "image/jpeg"
:keep-aspect-ratio true
:name "sample"}}]
coerced (fills/from-plain fill-vec)
plain (into [] coerced)]
(is (= 1 (count plain)))
(is (= 0.9 (:fill-opacity (first plain))))
(is (= 800 (-> plain first :fill-image :width)))
(is (= 600 (-> plain first :fill-image :height)))
(is (true? (-> plain first :fill-image :keep-aspect-ratio)))
(is (nil? (-> plain first :fill-image :transform)))))
(testing "roundtrip image fill with transform"
(let [fill-vec [{:fill-opacity 0.75
:fill-image {:id (uuid/custom 2)
:width 1920
:height 1080
:mtype "image/webp"
:keep-aspect-ratio false
:name "sample"
:transform {:x 0.25 :y -0.15 :width 1.5 :height 2.0}}}]
coerced (fills/from-plain fill-vec)
plain (into [] coerced)
tf (-> plain first :fill-image :transform)]
(is (= 1 (count plain)))
(is (= 0.75 (:fill-opacity (first plain))))
(is (= 1920 (-> plain first :fill-image :width)))
(is (= 1080 (-> plain first :fill-image :height)))
(is (false? (-> plain first :fill-image :keep-aspect-ratio)))
(is (some? tf))
(is (mth/close? 0.25 (double (:x tf))))
(is (mth/close? -0.15 (double (:y tf))))
(is (mth/close? 1.5 (double (:width tf))))
(is (mth/close? 2.0 (double (:height tf)))))))
(defn compute-bounds-resize-transform
"Mathematical model for independent image bounds resizing"
[{:keys [width height handler center? sx sy transform]}]
(let [w-new (* width sx)
h-new (* height sy)
[dx dy] (if ^boolean center?
[(/ (* width (- 1.0 sx)) 2.0)
(/ (* height (- 1.0 sy)) 2.0)]
[(case handler
(:left :bottom-left :top-left) (* width (- 1.0 sx))
0.0)
(case handler
(:top :top-left :top-right) (* height (- 1.0 sy))
0.0)])
nx0 (get transform :x 0.0)
ny0 (get transform :y 0.0)
nw0 (get transform :width 1.0)
nh0 (get transform :height 1.0)
nx' (/ (- (* nx0 width) dx) w-new)
ny' (/ (- (* ny0 height) dy) h-new)
nw' (/ nw0 sx)
nh' (/ nh0 sy)]
{:transform {:x nx' :y ny' :width nw' :height nh'}
:rendered-pixel-rect {:x (* nx' w-new)
:y (* ny' h-new)
:width (* nw' w-new)
:height (* nh' h-new)}}))
(deftest test-handle-anchoring-mathematics
(testing "Right handle crop (shrinking width to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 1.0 (-> res :transform :height)))
;; Rendered pixel content remains 200x100 starting at (0, 0)
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Left handle crop (shrinking width to 50% from left)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :left :center? false :sx 0.5 :sy 1.0})]
(is (mth/close? -1.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 1.0 (-> res :transform :height)))
;; Rendered pixel content has left at -100, width 200 -> right edge at +100 (matches right edge of 100px container!)
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))))
(testing "Top handle crop (shrinking height to 50% from top)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :top :center? false :sx 1.0 :sy 0.5})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? -1.0 (-> res :transform :y)))
(is (mth/close? 1.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
;; Rendered pixel content has top at -50, height 100 -> bottom edge at +50 (matches bottom edge of 50px container!)
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Top-Left handle crop (shrinking both dimensions to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :top-left :center? false :sx 0.5 :sy 0.5})]
(is (mth/close? -1.0 (-> res :transform :x)))
(is (mth/close? -1.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Center resize (Alt modifier)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? true :sx 0.5 :sy 0.5})]
(is (mth/close? -0.5 (-> res :transform :x)))
(is (mth/close? -0.5 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? -25.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Bottom handle crop (shrinking height to 50% from bottom)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :bottom :center? false :sx 1.0 :sy 0.5})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 1.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Top-Right handle crop (shrinking both dimensions to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :top-right :center? false :sx 0.5 :sy 0.5})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? -1.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Bottom-Left handle crop (shrinking both dimensions to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :bottom-left :center? false :sx 0.5 :sy 0.5})]
(is (mth/close? -1.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Expanding bounds beyond original size (empty space exposure)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 2.0 :sy 1.0})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 0.5 (-> res :transform :width)))
(is (mth/close? 1.0 (-> res :transform :height)))
;; Rendered pixel content is 200px wide in a 400px container -> exposes 200px empty space
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))))))
(deftest test-sequential-resize-operations
(testing "Sequential crops: crop right then crop left"
;; Initial shape: 200x100, transform: {:x 0 :y 0 :width 1 :height 1}
;; Step 1: Crop right handle from 200 to 150 (sx = 0.75)
(let [step1 (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 0.75 :sy 1.0})
tf1 (:transform step1)]
(is (mth/close? 0.0 (:x tf1)))
(is (mth/close? (/ 1.0 0.75) (:width tf1)))
;; Step 2: Now shape is 150x100 with tf1. Crop left handle from 150 to 100 (sx = 100/150 = 2/3)
(let [step2 (compute-bounds-resize-transform
{:width 150 :height 100 :handler :left :center? false :sx (/ 2.0 3.0) :sy 1.0 :transform tf1})
tf2 (:transform step2)]
;; The final 100x100 container has bitmap with width 200px
(is (mth/close? 200.0 (-> step2 :rendered-pixel-rect :width)))
;; The bitmap left edge is at -50px in the 100px container, so right edge is at -50 + 200 = 150px
(is (mth/close? -50.0 (-> step2 :rendered-pixel-rect :x))))))
(testing "Bounds resize followed by standard proportional scaling"
;; Step 1: Bounds resize crops width from 200 to 100
(let [step1 (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})
tf1 (:transform step1)]
(is (mth/close? 2.0 (:width tf1)))
(is (mth/close? 1.0 (:height tf1)))
;; Step 2: Standard proportional scale of the 100x100 cropped shape to 200x200 (scale 2x)
;; During standard scale, normalized transform tf1 is kept constant!
(let [scaled-w (* 100.0 2.0)
scaled-h (* 100.0 2.0)
rendered-w (* (:width tf1) scaled-w)
rendered-h (* (:height tf1) scaled-h)]
;; The underlying bitmap scaled from 200x100 to 400x200, matching the 2x scale of the cropped frame!
(is (mth/close? 400.0 rendered-w))
(is (mth/close? 200.0 rendered-h))))))
(deftest test-proportion-lock-invariance
(testing "Shape proportion-lock attribute remains unchanged"
(let [shape {:id (uuid/custom 10)
:type :rect
:width 200
:height 100
:proportion-lock true
:fills [{:fill-image {:id (uuid/custom 1)
:width 800
:height 600
:keep-aspect-ratio true}}]}
;; Simulate bounds resize interaction
has-img? (boolean (or (some :fill-image (:fills shape)) (:fill-image shape)))
mod-pressed? true
bounds-resize? (and has-img? mod-pressed?)
lock-during-drag (if bounds-resize? false (:proportion-lock shape))]
;; During drag, lock is bypassed (unless Shift is pressed)
(is (false? lock-during-drag))
;; Shape's persistent setting is completely preserved
(is (true? (:proportion-lock shape))))))
+2
View File
@@ -29,6 +29,7 @@
[common-tests.geom-flex-layout-test]
[common-tests.geom-grid-layout-test]
[common-tests.geom-grid-test]
[common-tests.geom-image-bounds-resize-test]
[common-tests.geom-line-test]
[common-tests.geom-modif-tree-test]
[common-tests.geom-modifiers-test]
@@ -108,6 +109,7 @@
'common-tests.geom-flex-layout-test
'common-tests.geom-grid-layout-test
'common-tests.geom-grid-test
'common-tests.geom-image-bounds-resize-test
'common-tests.geom-line-test
'common-tests.geom-modif-tree-test
'common-tests.geom-modifiers-test
@@ -99,7 +99,9 @@
:layout-item-margin-type
:layout-grid-cells
:layout-grid-columns
:layout-grid-rows})
:layout-grid-rows
:fills
:fill-image})
;; -- temporary modifiers -------------------------------------------
@@ -149,10 +149,15 @@
;; -- Resize --------------------------------------------------------
(defn- shape-has-image-fill?
[shape]
(boolean (or (some :fill-image (:fills shape))
(:fill-image shape))))
(defn start-resize
"Enter mouse resize mode, until mouse button is released."
[handler ids shape]
(letfn [(resize [shape initial layout objects [point lock? center? point-snap]]
(letfn [(resize [shape initial layout objects [point lock? center? bounds-resize? point-snap]]
(let [selrect (dm/get-prop shape :selrect)
width (dm/get-prop selrect :width)
height (dm/get-prop selrect :height)
@@ -235,7 +240,59 @@
(not (mth/close? (dm/get-prop scalev :x) 1))
change-height?
(not (mth/close? (dm/get-prop scalev :y) 1))]
(not (mth/close? (dm/get-prop scalev :y) 1))
;; Calculate independent image bounds resize transform
sx (dm/get-prop scalev :x)
sy (dm/get-prop scalev :y)
w-new (* width sx)
h-new (* height sy)
bounds-resize? (and ^boolean bounds-resize?
(pos? w-new)
(pos? h-new))
[dx dy] (if ^boolean center?
[(/ (* width (- 1.0 sx)) 2.0)
(/ (* height (- 1.0 sy)) 2.0)]
[(case handler
(:left :bottom-left :top-left) (* width (- 1.0 sx))
0.0)
(case handler
(:top :top-left :top-right) (* height (- 1.0 sy))
0.0)])
new-fills
(when (and bounds-resize? (seq (:fills shape)))
(mapv (fn [fill]
(if-let [img-fill (:fill-image fill)]
(let [tf (get img-fill :transform)
nx0 (get tf :x 0.0)
ny0 (get tf :y 0.0)
nw0 (get tf :width 1.0)
nh0 (get tf :height 1.0)
nx' (/ (- (* nx0 width) dx) w-new)
ny' (/ (- (* ny0 height) dy) h-new)
nw' (/ nw0 sx)
nh' (/ nh0 sy)]
(assoc-in fill [:fill-image :transform]
{:x nx' :y ny' :width nw' :height nh'}))
fill))
(:fills shape)))
new-fill-image
(when (and bounds-resize? (some? (:fill-image shape)))
(let [img-fill (:fill-image shape)
tf (get img-fill :transform)
nx0 (get tf :x 0.0)
ny0 (get tf :y 0.0)
nw0 (get tf :width 1.0)
nh0 (get tf :height 1.0)
nx' (/ (- (* nx0 width) dx) w-new)
ny' (/ (- (* ny0 height) dy) h-new)
nw' (/ nw0 sx)
nh' (/ nh0 sy)]
(assoc img-fill :transform {:x nx' :y ny' :width nw' :height nh'})))]
(cond-> (ctm/empty)
(some? displacement)
@@ -258,18 +315,30 @@
(and new-grow-type (not= new-grow-type (dm/get-prop shape :grow-type)))
(ctm/change-property :grow-type new-grow-type)
(and bounds-resize? (some? new-fills))
(ctm/change-property :fills new-fills)
(and bounds-resize? (some? new-fill-image))
(ctm/change-property :fill-image new-fill-image)
^boolean scale-text
(ctm/scale-content (dm/get-prop scalev :x)))))
;; Unifies the instantaneous proportion lock modifier
;; activated by Shift key and the shapes own proportion
;; lock flag that can be activated on element options.
(normalize-proportion-lock [[point shift? alt?]]
(let [proportion-lock? (:proportion-lock shape)]
(normalize-proportion-lock [[point shift? alt? mod?]]
(let [has-img? (shape-has-image-fill? shape)
bounds-resize? (and has-img? (boolean mod?))
proportion-lock? (:proportion-lock shape)
lock? (if bounds-resize?
(boolean shift?)
(or ^boolean proportion-lock?
^boolean shift?))]
[point
(or ^boolean proportion-lock?
^boolean shift?)
alt?]))]
lock?
alt?
bounds-resize?]))]
(reify
ptk/UpdateEvent
(update [_ state]
@@ -297,10 +366,10 @@
resize-events-stream
(->> ms/mouse-position
(rx/filter some?)
(rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt)
(rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt ms/mouse-position-mod)
(rx/map normalize-proportion-lock)
(rx/switch-map
(fn [[point _ _ :as current]]
(fn [[point _ _ _ :as current]]
(->> (snap/closest-snap-point page-id shapes objects layout zoom focus point)
(rx/map #(conj current %)))))
(rx/map #(resize shape initial-position layout objects %))
+28 -16
View File
@@ -119,31 +119,43 @@
(if (:fill-image value)
(let [uri (cf/resolve-file-media (:fill-image value))
keep-ar? (-> value :fill-image :keep-aspect-ratio)
tf (-> value :fill-image :transform)
img-x (if (some? tf) (* (get tf :x 0) width) 0)
img-y (if (some? tf) (* (get tf :y 0) height) 0)
img-w (if (some? tf) (* (get tf :width 1) width) width)
img-h (if (some? tf) (* (get tf :height 1) height) height)
image-props #js {:id (dm/str "fill-image-" render-id "-" fill-index)
:href (get embed uri uri)
:preserveAspectRatio (if keep-ar? "xMidYMid slice" "none")
:width width
:height height
:x img-x
:y img-y
:width img-w
:height img-h
:key (dm/str fill-index)
:opacity (:fill-opacity value)}]
[:> :image image-props])
[:> :rect props])))
(when ^boolean has-image?
[:g
;; We add this shape to add a padding so the patter won't repeat
;; Issue: https://tree.taiga.io/project/penpot/issue/5583
[:rect {:x 0
:y 0
:width (* width no-repeat-padding)
:height (* height no-repeat-padding)
:fill "none"}]
[:image {:href uri
:preserveAspectRatio "none"
:x 0
:y 0
:width width
:height height}]])]])])))
(let [tf (-> image :transform)
img-x (if (some? tf) (* (get tf :x 0) width) 0)
img-y (if (some? tf) (* (get tf :y 0) height) 0)
img-w (if (some? tf) (* (get tf :width 1) width) width)
img-h (if (some? tf) (* (get tf :height 1) height) height)]
[:g
;; We add this shape to add a padding so the patter won't repeat
;; Issue: https://tree.taiga.io/project/penpot/issue/5583
[:rect {:x 0
:y 0
:width (* width no-repeat-padding)
:height (* height no-repeat-padding)
:fill "none"}]
[:image {:href uri
:preserveAspectRatio "none"
:x img-x
:y img-y
:width img-w
:height img-h}]]))]])])))
(mf/defc fills
{::mf/wrap-props false}
+36 -15
View File
@@ -3,6 +3,7 @@ use skia_safe::{self as skia, Paint, RRect};
use super::{filters, RenderState, SurfaceId};
use crate::error::Result;
use crate::get_resources;
use crate::math::Rect as MathRect;
use crate::render::get_source_rect;
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
@@ -91,11 +92,20 @@ fn draw_image_fill(
let size = image.dimensions();
let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id);
let container = &shape.selrect;
let src_rect = get_source_rect(size, container, image_fill);
let dest_rect = container;
let sampling = get_resources().sampling_options;
let dest_rect = match image_fill.transform() {
Some(tf) => MathRect::from_xywh(
container.left + tf.x * container.width(),
container.top + tf.y * container.height(),
tf.width * container.width(),
tf.height * container.height(),
),
None => *container,
};
let src_rect = get_source_rect(size, &dest_rect, image_fill);
let needs_clip = image_fill.transform().is_some() || !is_axis_aligned_image_rect(shape);
// `save_layer` is only required when a shape-level image filter (blur) must
// run over the clipped image. Otherwise a plain save/clip (or no clip for
// axis-aligned rects) avoids an offscreen buffer per fill — the hot path
@@ -121,7 +131,7 @@ fn draw_image_fill(
let mut draw_paint = paint.clone();
draw_paint.set_anti_alias(antialias);
if is_axis_aligned_image_rect(shape) {
if !needs_clip {
canvas.draw_image_rect_with_sampling_options(
image,
Some((&src_rect, skia::canvas::SrcRectConstraint::Strict)),
@@ -163,10 +173,6 @@ fn draw_svg_image_fill(
let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id);
let container = &shape.selrect;
let size = skia::ISize::new(size.width as i32, size.height as i32);
let src_rect = get_source_rect(size, container, image_fill);
if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 {
return true;
}
let mut image_paint = skia::Paint::default();
image_paint.set_anti_alias(antialias);
@@ -183,16 +189,31 @@ fn draw_svg_image_fill(
let fill_layer = skia::canvas::SaveLayerRec::default().paint(paint);
canvas.save_layer(&fill_layer);
// Map the cropped source rect onto the container: cover semantics when
// keep-aspect-ratio is set, stretch otherwise (same math as the raster
// path, expressed as a canvas transform).
let scale_x = container.width() / src_rect.width();
let scale_y = container.height() / src_rect.height();
let dest_rect = match image_fill.transform() {
Some(tf) => MathRect::from_xywh(
container.left + tf.x * container.width(),
container.top + tf.y * container.height(),
tf.width * container.width(),
tf.height * container.height(),
),
None => *container,
};
let src_rect = get_source_rect(size, &dest_rect, image_fill);
if src_rect.width() <= 0.0 || src_rect.height() <= 0.0 {
canvas.restore();
canvas.restore();
return true;
}
let scale_x = dest_rect.width() / src_rect.width();
let scale_y = dest_rect.height() / src_rect.height();
canvas.translate((
container.left - src_rect.left * scale_x,
container.top - src_rect.top * scale_y,
dest_rect.left - src_rect.left * scale_x,
dest_rect.top - src_rect.top * scale_y,
));
canvas.scale((scale_x, scale_y));
dom.render(canvas);
canvas.restore();
+32
View File
@@ -118,6 +118,14 @@ impl Gradient {
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
pub struct ImageFillTransform {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImageFill {
id: Uuid,
@@ -125,6 +133,7 @@ pub struct ImageFill {
width: i32,
height: i32,
keep_aspect_ratio: bool,
transform: Option<ImageFillTransform>,
}
impl ImageFill {
@@ -135,6 +144,25 @@ impl ImageFill {
width,
height,
keep_aspect_ratio,
transform: None,
}
}
pub fn new_with_transform(
id: Uuid,
opacity: u8,
width: i32,
height: i32,
keep_aspect_ratio: bool,
transform: Option<ImageFillTransform>,
) -> Self {
Self {
id,
opacity,
width,
height,
keep_aspect_ratio,
transform,
}
}
@@ -157,6 +185,10 @@ impl ImageFill {
pub fn height(&self) -> i32 {
self.height
}
pub fn transform(&self) -> Option<&ImageFillTransform> {
self.transform.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
+26
View File
@@ -187,4 +187,30 @@ mod tests {
assert_eq!(bytes[0], 0x03);
assert_eq!(shapes::Fill::from(RawFillData::from(bytes)), fill);
}
#[test]
fn test_image_fill_with_transform_round_trip() {
let transform = shapes::ImageFillTransform {
x: 0.1,
y: -0.2,
width: 1.5,
height: 2.0,
};
let image_fill = shapes::ImageFill::new_with_transform(
crate::uuid::Uuid::nil(),
0xcc,
400,
300,
false,
Some(transform),
);
let fill = shapes::Fill::Image(image_fill);
let raw_fill =
RawFillData::try_from(&fill).expect("image fill with transform must be serializable");
let bytes = <[u8; RAW_FILL_DATA_SIZE]>::from(raw_fill);
assert_eq!(bytes[0], 0x03);
let deserialized = shapes::Fill::from(RawFillData::from(bytes));
assert_eq!(deserialized, fill);
}
}
+30 -3
View File
@@ -30,6 +30,7 @@ fn touch_shapes_with_image(state: &mut State, image_id: Uuid) {
}
const FLAG_KEEP_ASPECT_RATIO: u8 = 1 << 0;
const FLAG_HAS_TRANSFORM: u8 = 1 << 1;
const IMAGE_IDS_SIZE: usize = 32;
const IMAGE_HEADER_SIZE: usize = 36; // 32 bytes for IDs + 4 bytes for is_thumbnail flag
@@ -43,20 +44,30 @@ pub struct RawImageFillData {
d: u32,
opacity: u8,
flags: u8,
// 16-bit padding here, reserved for future use
_pad: u16,
width: i32,
height: i32,
transform_x: f32,
transform_y: f32,
transform_w: f32,
transform_h: f32,
}
impl From<&ImageFill> for RawImageFillData {
fn from(image_fill: &ImageFill) -> Self {
let id = image_fill.id();
let (a, b, c, d) = crate::utils::uuid_to_u32_quartet(&id);
let flags = if image_fill.keep_aspect_ratio() {
let mut flags = if image_fill.keep_aspect_ratio() {
FLAG_KEEP_ASPECT_RATIO
} else {
0
};
let (tx, ty, tw, th) = if let Some(tf) = image_fill.transform() {
flags |= FLAG_HAS_TRANSFORM;
(tf.x, tf.y, tf.width, tf.height)
} else {
(0.0, 0.0, 1.0, 1.0)
};
Self {
a,
@@ -65,8 +76,13 @@ impl From<&ImageFill> for RawImageFillData {
d,
opacity: image_fill.opacity(),
flags,
_pad: 0,
width: image_fill.width(),
height: image_fill.height(),
transform_x: tx,
transform_y: ty,
transform_w: tw,
transform_h: th,
}
}
}
@@ -75,13 +91,24 @@ impl From<RawImageFillData> for ImageFill {
fn from(value: RawImageFillData) -> Self {
let id = uuid_from_u32_quartet(value.a, value.b, value.c, value.d);
let keep_aspect_ratio = value.flags & FLAG_KEEP_ASPECT_RATIO != 0;
let transform = if value.flags & FLAG_HAS_TRANSFORM != 0 {
Some(crate::shapes::ImageFillTransform {
x: value.transform_x,
y: value.transform_y,
width: value.transform_w,
height: value.transform_h,
})
} else {
None
};
Self::new(
Self::new_with_transform(
id,
value.opacity,
value.width,
value.height,
keep_aspect_ratio,
transform,
)
}
}