🎉 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>
This commit is contained in:
Danny Shirely 2026-09-09 16:46:56 +03:30 committed by GitHub
parent bf2fcba3be
commit d45c6710b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 568 additions and 72 deletions

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"

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

View File

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

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

View File

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

View File

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

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}

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

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

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

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