mirror of
https://github.com/penpot/penpot.git
synced 2026-09-11 05:09:34 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d0680ecf | ||
|
|
d45c6710b7 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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))]
|
||||
|
||||
|
||||
@@ -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)))))))
|
||||
@@ -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"
|
||||
|
||||
@@ -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))))))
|
||||
@@ -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 %))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user