Compare commits

...
Author SHA1 Message Date
alonso.torres 7861631058 🐛 Fix handler change to equal 2026-09-10 12:31:29 +02:00
Elena Torró 9b24907992 🐛 Fix render text-decoration on spans and update regression tests (#11583) 2026-09-10 09:42:09 +02:00
andrés gonzálezandCursor 6f63a5fcbf Persist hide resolved comments preference (#10694)
Store the hide-resolved filter in user storage and restore it when
entering the workspace or viewer, consistent with canvas comment
visibility from #10239. Match the comments filter separator styling to
the main menu and add the missing mentions option in the viewer
dropdown.

Closes #10686

Signed-off-by: Andres Gonzalez <andres.gonzalez79@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 08:14:37 +02:00
Elena Torró 61caefbcf3 🔧 Support independent image bounds on wasm export (#11590) 2026-09-09 18:27:22 +02:00
Elena Torró 6586631293 🐛 Fix wasm boolean paths with opposite winding operand (#11551) 2026-09-09 18:19:29 +02:00
Andrey Antukh 66fb4a69ba 📎 Update copyright headers 2026-09-09 17:58:09 +02:00
Elena Torró 3d4a5ca2aa 🔧 Use mutex so only one test owns global at a time (#11585) 2026-09-09 17:10:04 +02:00
Danny ShirelyandAndrey Antukh d45c6710b7 🎉 Implement independent image bounds resizing (#11430)
* 🎉 Implement independent image bounds resizing

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

AI-assisted-by: gemini-2.5-pro

* ♻️ Address reviewer feedback from elenatorro

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

* 🔧 Fix clippy needless borrow warnings in wasm image fills

---------

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

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.arrow
"Bulk Ladybug ingest through in-memory Arrow.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.debug
"In-memory Ladybug sessions for the debug graph console."
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.ingest
"Penpot file -> Ladybug graph projection."
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.ladybug
"Ladybug access layer for graph-backed Penpot.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.meta
"`GraphMeta`: the graph's own account of who built it and from what.
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.projection.document
"Project a Penpot file-data map into Ladybug nodes and structural edges.
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.projection.transforms
"Derived graph links: edges a reader could compute from the projected
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.report
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema
"Ladybug DDL facade for the graph-backed Penpot vertical slice.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.contract
"Deliberate choices in Penpot's graph schema, recorded as data.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.nodes
"Single source of truth for graph node tables.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.projection
"Derive Ladybug node column schemas from Penpot Malli sources.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.types
"Map Malli schemas to Ladybug column types.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.schema.values
"Shape a Penpot value into the plain data its Ladybug column type wants.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.stats
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.graph.sync
"Incremental Ladybug graph updates from Penpot file-change events."
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.rpc.commands.plugins
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.storage.pending-gc
"A maintenance task that reclaims storage objects created in 'pending'
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.tasks.demo-purge
"Task handler for delayed demo profile deletion. Submitted at demo
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.demo-test
(:require
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.graph-binder-gate-test
"Binder gate for the incremental-sync statement templates.
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.graph-sync-parity-test
"Cold projection and incremental sync are two implementations of one mapping,
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.passwords-test
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-demo-test
(:require
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.rpc-plugins-test
(:require
+9 -1
View File
@@ -72,6 +72,13 @@
[:map {:title "PlainColorAttrs"}
[:color schema:hex-color]])
(def schema:image-transform
[:map {:title "ImageTransform" :closed true}
[:x {:optional true} ::sm/safe-number]
[:y {:optional true} ::sm/safe-number]
[:width {:optional true} ::sm/safe-number]
[:height {:optional true} ::sm/safe-number]])
(def schema:image
[:map {:title "ImageColor" :closed true}
[:width [::sm/int {:min 0 :gen/gen sg/int}]]
@@ -79,7 +86,8 @@
[:mtype {:gen/gen (sg/elements cm/image-types)} ::sm/text]
[:id ::sm/uuid]
[:name {:optional true} ::sm/text]
[:keep-aspect-ratio {:optional true} :boolean]])
[:keep-aspect-ratio {:optional true} :boolean]
[:transform {:optional true} schema:image-transform]])
(def image-attrs
"A set of attrs that corresponds to image data type"
+49 -27
View File
@@ -119,12 +119,15 @@
(defn write-image-fill
[offset buffer opacity image]
(let [image-id (get image :id)
image-width (get image :width)
image-height (get image :height)
alpha (mth/floor (* opacity 0xff))
keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
flags (bit-or keep-aspect-ratio 0x00)]
(let [image-id (get image :id)
image-width (get image :width)
image-height (get image :height)
alpha (mth/floor (* opacity 0xff))
keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
transform (get image :transform)
has-transform? (some? transform)
transform-flag (if has-transform? 0x02 0x00)
flags (bit-or keep-aspect-ratio transform-flag)]
(buf/write-byte buffer (+ offset 0) 0x03)
(buf/write-uuid buffer (+ offset 4) image-id)
(buf/write-byte buffer (+ offset 20) alpha)
@@ -132,6 +135,17 @@
(buf/write-short buffer (+ offset 22) 0) ;; 2-byte padding (reserved for future use)
(buf/write-int buffer (+ offset 24) image-width)
(buf/write-int buffer (+ offset 28) image-height)
(if has-transform?
(do
(buf/write-float buffer (+ offset 32) (double (get transform :x 0.0)))
(buf/write-float buffer (+ offset 36) (double (get transform :y 0.0)))
(buf/write-float buffer (+ offset 40) (double (get transform :width 1.0)))
(buf/write-float buffer (+ offset 44) (double (get transform :height 1.0))))
(do
(buf/write-float buffer (+ offset 32) 0.0)
(buf/write-float buffer (+ offset 36) 0.0)
(buf/write-float buffer (+ offset 40) 1.0)
(buf/write-float buffer (+ offset 44) 1.0)))
(+ offset FILL-U8-SIZE)))
(defn- write-metadata
@@ -208,28 +222,36 @@
:type type}})
3 ;; image fill
(let [id (buf/read-uuid dbuffer (+ doffset 4))
alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
opacity (mth/precision (/ alpha 0xff) 2)
flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
ratio (boolean (bit-and flags 0x01))
width (buf/read-int dbuffer (+ doffset 24))
height (buf/read-int dbuffer (+ doffset 28))
mtype (buf/read-short mbuffer (+ moffset 2))
mtype (case mtype
0x01 "image/jpeg"
0x02 "image/png"
0x03 "image/gif"
0x04 "image/webp"
0x05 "image/svg+xml")]
(let [id (buf/read-uuid dbuffer (+ doffset 4))
alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
opacity (mth/precision (/ alpha 0xff) 2)
flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
ratio (not (zero? (bit-and flags 0x01)))
has-tf (not (zero? (bit-and flags 0x02)))
width (buf/read-int dbuffer (+ doffset 24))
height (buf/read-int dbuffer (+ doffset 28))
transform (when has-tf
{:x (buf/read-float dbuffer (+ doffset 32))
:y (buf/read-float dbuffer (+ doffset 36))
:width (buf/read-float dbuffer (+ doffset 40))
:height (buf/read-float dbuffer (+ doffset 44))})
mtype (buf/read-short mbuffer (+ moffset 2))
mtype (case mtype
0x01 "image/jpeg"
0x02 "image/png"
0x03 "image/gif"
0x04 "image/webp"
0x05 "image/svg+xml")]
{:fill-opacity opacity
:fill-image {:id id
:width width
:height height
:mtype mtype
:keep-aspect-ratio ratio
;; FIXME: we are not encodign the name, looks useless
:name "sample"}}))]
:fill-image (cond-> {:id id
:width width
:height height
:mtype mtype
:keep-aspect-ratio ratio
;; FIXME: we are not encodign the name, looks useless
:name "sample"}
(some? transform)
(assoc :transform transform))}))]
(if refs?
(let [ref-file (buf/read-uuid mbuffer (+ moffset 4))
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.fit
"Curve fitting helpers."
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.path.selection
"Transforms selected path nodes and handlers."
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.types.tokens-status
(:require
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.files-migrations-0026-test
(:require
@@ -0,0 +1,275 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.geom-image-bounds-resize-test
(:require
#?(:clj [clojure.test :refer [deftest is testing]]
:cljs [cljs.test :refer-macros [deftest is testing]])
[app.common.math :as mth]
[app.common.schema :as sm]
[app.common.types.color :as clr]
[app.common.types.fills :as fills]
[app.common.types.fills.impl :as fills.impl]
[app.common.uuid :as uuid]))
(deftest test-image-transform-schema
(testing "validates image with transform"
(let [img {:id (uuid/custom 1)
:width 400
:height 300
:mtype "image/png"
:keep-aspect-ratio true
:transform {:x 0.1 :y -0.2 :width 1.5 :height 2.0}}]
(is (sm/validate clr/schema:image img))))
(testing "validates image without transform"
(let [img {:id (uuid/custom 1)
:width 400
:height 300
:mtype "image/png"
:keep-aspect-ratio true}]
(is (sm/validate clr/schema:image img))))
(testing "validates fill with image transform"
(let [fill {:fill-opacity 0.8
:fill-image {:id (uuid/custom 1)
:width 400
:height 300
:mtype "image/png"
:keep-aspect-ratio true
:transform {:x -0.5 :y -0.5 :width 2.0 :height 2.0}}}]
(is (sm/validate fills/schema:fill fill)))))
(deftest test-image-fill-buffer-roundtrip
(testing "roundtrip image fill without transform"
(let [fill-vec [{:fill-opacity 0.9
:fill-image {:id (uuid/custom 1)
:width 800
:height 600
:mtype "image/jpeg"
:keep-aspect-ratio true
:name "sample"}}]
coerced (fills/from-plain fill-vec)
plain (into [] coerced)]
(is (= 1 (count plain)))
(is (= 0.9 (:fill-opacity (first plain))))
(is (= 800 (-> plain first :fill-image :width)))
(is (= 600 (-> plain first :fill-image :height)))
(is (true? (-> plain first :fill-image :keep-aspect-ratio)))
(is (nil? (-> plain first :fill-image :transform)))))
(testing "roundtrip image fill with transform"
(let [fill-vec [{:fill-opacity 0.75
:fill-image {:id (uuid/custom 2)
:width 1920
:height 1080
:mtype "image/webp"
:keep-aspect-ratio false
:name "sample"
:transform {:x 0.25 :y -0.15 :width 1.5 :height 2.0}}}]
coerced (fills/from-plain fill-vec)
plain (into [] coerced)
tf (-> plain first :fill-image :transform)]
(is (= 1 (count plain)))
(is (= 0.75 (:fill-opacity (first plain))))
(is (= 1920 (-> plain first :fill-image :width)))
(is (= 1080 (-> plain first :fill-image :height)))
(is (false? (-> plain first :fill-image :keep-aspect-ratio)))
(is (some? tf))
(is (mth/close? 0.25 (double (:x tf))))
(is (mth/close? -0.15 (double (:y tf))))
(is (mth/close? 1.5 (double (:width tf))))
(is (mth/close? 2.0 (double (:height tf)))))))
(defn compute-bounds-resize-transform
"Mathematical model for independent image bounds resizing"
[{:keys [width height handler center? sx sy transform]}]
(let [w-new (* width sx)
h-new (* height sy)
[dx dy] (if ^boolean center?
[(/ (* width (- 1.0 sx)) 2.0)
(/ (* height (- 1.0 sy)) 2.0)]
[(case handler
(:left :bottom-left :top-left) (* width (- 1.0 sx))
0.0)
(case handler
(:top :top-left :top-right) (* height (- 1.0 sy))
0.0)])
nx0 (get transform :x 0.0)
ny0 (get transform :y 0.0)
nw0 (get transform :width 1.0)
nh0 (get transform :height 1.0)
nx' (/ (- (* nx0 width) dx) w-new)
ny' (/ (- (* ny0 height) dy) h-new)
nw' (/ nw0 sx)
nh' (/ nh0 sy)]
{:transform {:x nx' :y ny' :width nw' :height nh'}
:rendered-pixel-rect {:x (* nx' w-new)
:y (* ny' h-new)
:width (* nw' w-new)
:height (* nh' h-new)}}))
(deftest test-handle-anchoring-mathematics
(testing "Right handle crop (shrinking width to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 1.0 (-> res :transform :height)))
;; Rendered pixel content remains 200x100 starting at (0, 0)
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Left handle crop (shrinking width to 50% from left)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :left :center? false :sx 0.5 :sy 1.0})]
(is (mth/close? -1.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 1.0 (-> res :transform :height)))
;; Rendered pixel content has left at -100, width 200 -> right edge at +100 (matches right edge of 100px container!)
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))))
(testing "Top handle crop (shrinking height to 50% from top)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :top :center? false :sx 1.0 :sy 0.5})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? -1.0 (-> res :transform :y)))
(is (mth/close? 1.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
;; Rendered pixel content has top at -50, height 100 -> bottom edge at +50 (matches bottom edge of 50px container!)
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Top-Left handle crop (shrinking both dimensions to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :top-left :center? false :sx 0.5 :sy 0.5})]
(is (mth/close? -1.0 (-> res :transform :x)))
(is (mth/close? -1.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Center resize (Alt modifier)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? true :sx 0.5 :sy 0.5})]
(is (mth/close? -0.5 (-> res :transform :x)))
(is (mth/close? -0.5 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? -25.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Bottom handle crop (shrinking height to 50% from bottom)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :bottom :center? false :sx 1.0 :sy 0.5})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 1.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Top-Right handle crop (shrinking both dimensions to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :top-right :center? false :sx 0.5 :sy 0.5})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? -1.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? -50.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Bottom-Left handle crop (shrinking both dimensions to 50%)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :bottom-left :center? false :sx 0.5 :sy 0.5})]
(is (mth/close? -1.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 2.0 (-> res :transform :width)))
(is (mth/close? 2.0 (-> res :transform :height)))
(is (mth/close? -100.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :y)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width)))
(is (mth/close? 100.0 (-> res :rendered-pixel-rect :height)))))
(testing "Expanding bounds beyond original size (empty space exposure)"
(let [res (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 2.0 :sy 1.0})]
(is (mth/close? 0.0 (-> res :transform :x)))
(is (mth/close? 0.0 (-> res :transform :y)))
(is (mth/close? 0.5 (-> res :transform :width)))
(is (mth/close? 1.0 (-> res :transform :height)))
;; Rendered pixel content is 200px wide in a 400px container -> exposes 200px empty space
(is (mth/close? 0.0 (-> res :rendered-pixel-rect :x)))
(is (mth/close? 200.0 (-> res :rendered-pixel-rect :width))))))
(deftest test-sequential-resize-operations
(testing "Sequential crops: crop right then crop left"
;; Initial shape: 200x100, transform: {:x 0 :y 0 :width 1 :height 1}
;; Step 1: Crop right handle from 200 to 150 (sx = 0.75)
(let [step1 (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 0.75 :sy 1.0})
tf1 (:transform step1)]
(is (mth/close? 0.0 (:x tf1)))
(is (mth/close? (/ 1.0 0.75) (:width tf1)))
;; Step 2: Now shape is 150x100 with tf1. Crop left handle from 150 to 100 (sx = 100/150 = 2/3)
(let [step2 (compute-bounds-resize-transform
{:width 150 :height 100 :handler :left :center? false :sx (/ 2.0 3.0) :sy 1.0 :transform tf1})
tf2 (:transform step2)]
;; The final 100x100 container has bitmap with width 200px
(is (mth/close? 200.0 (-> step2 :rendered-pixel-rect :width)))
;; The bitmap left edge is at -50px in the 100px container, so right edge is at -50 + 200 = 150px
(is (mth/close? -50.0 (-> step2 :rendered-pixel-rect :x))))))
(testing "Bounds resize followed by standard proportional scaling"
;; Step 1: Bounds resize crops width from 200 to 100
(let [step1 (compute-bounds-resize-transform
{:width 200 :height 100 :handler :right :center? false :sx 0.5 :sy 1.0})
tf1 (:transform step1)]
(is (mth/close? 2.0 (:width tf1)))
(is (mth/close? 1.0 (:height tf1)))
;; Step 2: Standard proportional scale of the 100x100 cropped shape to 200x200 (scale 2x)
;; During standard scale, normalized transform tf1 is kept constant!
(let [scaled-w (* 100.0 2.0)
scaled-h (* 100.0 2.0)
rendered-w (* (:width tf1) scaled-w)
rendered-h (* (:height tf1) scaled-h)]
;; The underlying bitmap scaled from 200x100 to 400x200, matching the 2x scale of the cropped frame!
(is (mth/close? 400.0 rendered-w))
(is (mth/close? 200.0 rendered-h))))))
(deftest test-proportion-lock-invariance
(testing "Shape proportion-lock attribute remains unchanged"
(let [shape {:id (uuid/custom 10)
:type :rect
:width 200
:height 100
:proportion-lock true
:fills [{:fill-image {:id (uuid/custom 1)
:width 800
:height 600
:keep-aspect-ratio true}}]}
;; Simulate bounds resize interaction
has-img? (boolean (or (some :fill-image (:fills shape)) (:fill-image shape)))
mod-pressed? true
bounds-resize? (and has-img? mod-pressed?)
lock-during-drag (if bounds-resize? false (:proportion-lock shape))]
;; During drag, lock is bypassed (unless Shift is pressed)
(is (false? lock-during-drag))
;; Shape's persistent setting is completely preserved
(is (true? (:proportion-lock shape))))))
+2
View File
@@ -29,6 +29,7 @@
[common-tests.geom-flex-layout-test]
[common-tests.geom-grid-layout-test]
[common-tests.geom-grid-test]
[common-tests.geom-image-bounds-resize-test]
[common-tests.geom-line-test]
[common-tests.geom-modif-tree-test]
[common-tests.geom-modifiers-test]
@@ -108,6 +109,7 @@
'common-tests.geom-flex-layout-test
'common-tests.geom-grid-layout-test
'common-tests.geom-grid-test
'common-tests.geom-image-bounds-resize-test
'common-tests.geom-line-test
'common-tests.geom-modif-tree-test
'common-tests.geom-modifiers-test
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.types.tokens-status-test
(:require
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.auth
"Resolves the caller's session cookie to a real profile id.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.handlers.export
"Handle export jobs"
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.handlers.jobs
"REST surface for export jobs, under `/api/export/jobs`.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs
"Export job model and lifecycle.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs.scheduler
"Admission control for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs.store
"Redis persistence for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.jobs.utils
"Temp file ownership for export jobs.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.router
"Method + path dispatch.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.wasm.pool
"Pool of headless render workers.
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.wasm.render
"Headless render pipeline: renders exports with the render-wasm Skia pipeline,
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.wasm.worker
"Render worker entry point.
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.export-shapes-test
"Chunking of the browser backend."
+1 -1
View File
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.jobs-test
"Job state machine. Runs without redis: a store write with no connection is
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.scheduler-test
"Admission control. A headless job leases one render worker for its whole run,
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.wasm-pool-test
"Worker leasing, against a stub pool: `with-worker` must give the worker back
File diff suppressed because it is too large. Load diff
@@ -274,6 +274,25 @@ test("Renders a file with different text leaves decoration", async ({
await expect(workspace.canvas).toHaveScreenshot();
});
// Both paragraphs decorate the same spans; the first one paints every span with
// the same fill, which used to collapse the decorated spans into their
// neighbours and drop their underline / line-through.
test("Renders text spans decorated independently of their fill", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-text-span-decoration.json");
await workspace.goToWorkspace({
id: "1d0f6a4c-0000-8000-8006-000000000001",
pageId: "1d0f6a4c-0000-8000-8006-000000000002",
});
await workspace.waitForFirstRenderWithoutUI();
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with different text shadows combinations", async ({
page,
}) => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 110 KiB

+36 -1
View File
@@ -19,6 +19,7 @@
[app.main.data.team :as dtm]
[app.main.repo :as rp]
[app.util.i18n :as i18n :refer [tr]]
[app.util.storage :as storage]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
@@ -531,6 +532,35 @@
(update [_ state]
(update state :comments-local dissoc :expanded))))
(def ^:private hide-resolved-comments-storage-key
:app.main.data.comments/hide-resolved-comments?)
(defn- load-hide-resolved-comments?
[]
(= true (get @storage/user hide-resolved-comments-storage-key)))
(defn- persist-hide-resolved-comments!
[hide?]
(swap! storage/user assoc hide-resolved-comments-storage-key hide?))
(defn merge-persisted-filters
"Merge persisted hide-resolved preference into comments local state."
[local]
(let [local (or local {})]
(if (contains? local :show)
local
(assoc local :show (if (load-hide-resolved-comments?)
:pending
:all)))))
(defn initialize-comments-filters
"Load persisted comment filter preferences into `:comments-local`."
[]
(ptk/reify ::initialize-comments-filters
ptk/UpdateEvent
(update [_ state]
(update state :comments-local merge-persisted-filters))))
(defn update-filters
[{:keys [mode show list] :as params}]
(ptk/reify ::update-filters
@@ -546,7 +576,12 @@
(assoc :show show)
(some? list)
(assoc :list list)))))))
(assoc :list list)))))
ptk/EffectEvent
(effect [_ _ _]
(when (some? show)
(persist-hide-resolved-comments! (= :pending show))))))
(defn update-options
[params]
+2 -1
View File
@@ -77,7 +77,8 @@
(if (nil? lstate)
default-local-state
lstate)))
(assoc-in [:viewer-local :share-id] share-id)))
(assoc-in [:viewer-local :share-id] share-id)
(update :comments-local dcmt/merge-persisted-filters)))
ptk/WatchEvent
(watch [_ state _]
+2 -1
View File
@@ -403,7 +403,8 @@
(assoc :recent-fonts (:recent-fonts storage/user))
(assoc :current-file-id file-id)
(assoc :workspace-presence {})
(update :workspace-global dissoc :default-font)))
(update :workspace-global dissoc :default-font)
(update :comments-local dcmt/merge-persisted-filters)))
ptk/WatchEvent
(watch [_ state stream]
@@ -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 -------------------------------------------
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.data.workspace.path.clipboard
(:require
@@ -163,6 +163,7 @@
(-> state
(assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers)
(assoc-in [:workspace-local :edit-path id :moving-handler] moving-handler)
(assoc-in [:workspace-local :edit-path id :edited-handler] primary)
(cond-> (some? new-prev-handler)
(assoc-in [:workspace-local :edit-path id :prev-handler] new-prev-handler)))))))
@@ -286,7 +287,10 @@
content-modifiers))]
(-> state
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers))))))
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers)
(cond-> (= 1 (count handler-ids))
(assoc-in [:workspace-local :edit-path id :edited-handler]
(first handler-ids))))))))
(defn- move-node-indices
[state node-indices from-point to-point]
@@ -180,6 +180,34 @@
:else nil)))
(defn node-handler-ids
"Returns a node's curve handlers, its primary handle first."
[content node-index]
(if-let [[index prefix :as primary] (node-primary-handler content node-index)]
(let [[op-idx op-prefix] (path/opposite-index content index prefix)]
(if (some? op-idx)
[primary [op-idx op-prefix]]
[primary]))
[]))
(defn handler-type-reference
"Returns the handler that keeps its geometry when a node's handler type changes.
The other handler adapts to it. Priority: the node's only selected handler,
then its most recently edited handler, then its primary handle."
[content selection edited-handler node-index]
(let [handler-ids (node-handler-ids content node-index)
selected (filterv (get selection :handlers #{}) handler-ids)]
(cond
(= 1 (count selected))
(first selected)
(some #{edited-handler} handler-ids)
edited-handler
:else
(first handler-ids))))
(defn handlers-equal-length?
"True when a node's two handlers are the same distance from the node."
[content index prefix]
@@ -49,7 +49,8 @@
(update-in [:workspace-local :edit-path id :selection]
#(helpers/remap-selection % old-content new-content))
(update-in [:workspace-local :edit-path id :handler-types]
#(helpers/remap-handler-types % old-content new-content))))
#(helpers/remap-handler-types % old-content new-content))
(update-in [:workspace-local :edit-path id] dissoc :edited-handler)))
state)))
ptk/WatchEvent
@@ -80,9 +81,11 @@
(reduce path/make-curve-point content))))))
(defn- apply-handler-type-modifiers
"Returns modifiers that reshape a node's handlers to `type`."
[content node-index type]
(if-let [[idx prefix] (helpers/node-primary-handler content node-index)]
"Returns modifiers that reshape a node's handlers to `type`.
`reference` keeps its geometry; the opposite handler adapts to it."
[content reference type]
(if-let [[idx prefix] reference]
(case type
:mirror (helpers/move-handler-modifiers content idx prefix true true true 0 0)
:aligned (helpers/align-handler-modifiers content idx prefix 0 0)
@@ -98,10 +101,13 @@
(let [id (st/get-path-id state)
content (st/get-path state :content)
selection (st/get-selection state id)
edited (dm/get-in state [:workspace-local :edit-path id :edited-handler])
nodes (helpers/handler-target-nodes content selection)]
(if (and (some? content) (seq nodes))
(let [modifiers (reduce (fn [acc node-index]
(d/deep-merge acc (apply-handler-type-modifiers content node-index type)))
(let [reference (helpers/handler-type-reference
content selection edited node-index)]
(d/deep-merge acc (apply-handler-type-modifiers content reference type))))
{} nodes)
new-content (path/apply-content-modifiers content modifiers)]
(-> (st/set-content state new-content)
@@ -146,7 +152,8 @@
(update-in [:workspace-local :edit-path id :selection]
#(helpers/remap-selection % old-content new-content))
(update-in [:workspace-local :edit-path id :handler-types]
#(helpers/remap-handler-types % old-content new-content)))))
#(helpers/remap-handler-types % old-content new-content))
(update-in [:workspace-local :edit-path id] dissoc :edited-handler))))
(defn remove-segments
"Removes segments and opens the path at their endpoints."
@@ -149,10 +149,15 @@
;; -- Resize --------------------------------------------------------
(defn- shape-has-image-fill?
[shape]
(boolean (or (some :fill-image (:fills shape))
(:fill-image shape))))
(defn start-resize
"Enter mouse resize mode, until mouse button is released."
[handler ids shape]
(letfn [(resize [shape initial layout objects [point lock? center? point-snap]]
(letfn [(resize [shape initial layout objects [point lock? center? bounds-resize? point-snap]]
(let [selrect (dm/get-prop shape :selrect)
width (dm/get-prop selrect :width)
height (dm/get-prop selrect :height)
@@ -235,7 +240,59 @@
(not (mth/close? (dm/get-prop scalev :x) 1))
change-height?
(not (mth/close? (dm/get-prop scalev :y) 1))]
(not (mth/close? (dm/get-prop scalev :y) 1))
;; Calculate independent image bounds resize transform
sx (dm/get-prop scalev :x)
sy (dm/get-prop scalev :y)
w-new (* width sx)
h-new (* height sy)
bounds-resize? (and ^boolean bounds-resize?
(pos? w-new)
(pos? h-new))
[dx dy] (if ^boolean center?
[(/ (* width (- 1.0 sx)) 2.0)
(/ (* height (- 1.0 sy)) 2.0)]
[(case handler
(:left :bottom-left :top-left) (* width (- 1.0 sx))
0.0)
(case handler
(:top :top-left :top-right) (* height (- 1.0 sy))
0.0)])
new-fills
(when (and bounds-resize? (seq (:fills shape)))
(mapv (fn [fill]
(if-let [img-fill (:fill-image fill)]
(let [tf (get img-fill :transform)
nx0 (get tf :x 0.0)
ny0 (get tf :y 0.0)
nw0 (get tf :width 1.0)
nh0 (get tf :height 1.0)
nx' (/ (- (* nx0 width) dx) w-new)
ny' (/ (- (* ny0 height) dy) h-new)
nw' (/ nw0 sx)
nh' (/ nh0 sy)]
(assoc-in fill [:fill-image :transform]
{:x nx' :y ny' :width nw' :height nh'}))
fill))
(:fills shape)))
new-fill-image
(when (and bounds-resize? (some? (:fill-image shape)))
(let [img-fill (:fill-image shape)
tf (get img-fill :transform)
nx0 (get tf :x 0.0)
ny0 (get tf :y 0.0)
nw0 (get tf :width 1.0)
nh0 (get tf :height 1.0)
nx' (/ (- (* nx0 width) dx) w-new)
ny' (/ (- (* ny0 height) dy) h-new)
nw' (/ nw0 sx)
nh' (/ nh0 sy)]
(assoc img-fill :transform {:x nx' :y ny' :width nw' :height nh'})))]
(cond-> (ctm/empty)
(some? displacement)
@@ -258,18 +315,30 @@
(and new-grow-type (not= new-grow-type (dm/get-prop shape :grow-type)))
(ctm/change-property :grow-type new-grow-type)
(and bounds-resize? (some? new-fills))
(ctm/change-property :fills new-fills)
(and bounds-resize? (some? new-fill-image))
(ctm/change-property :fill-image new-fill-image)
^boolean scale-text
(ctm/scale-content (dm/get-prop scalev :x)))))
;; Unifies the instantaneous proportion lock modifier
;; activated by Shift key and the shapes own proportion
;; lock flag that can be activated on element options.
(normalize-proportion-lock [[point shift? alt?]]
(let [proportion-lock? (:proportion-lock shape)]
(normalize-proportion-lock [[point shift? alt? mod?]]
(let [has-img? (shape-has-image-fill? shape)
bounds-resize? (and has-img? (boolean mod?))
proportion-lock? (:proportion-lock shape)
lock? (if bounds-resize?
(boolean shift?)
(or ^boolean proportion-lock?
^boolean shift?))]
[point
(or ^boolean proportion-lock?
^boolean shift?)
alt?]))]
lock?
alt?
bounds-resize?]))]
(reify
ptk/UpdateEvent
(update [_ state]
@@ -297,10 +366,10 @@
resize-events-stream
(->> ms/mouse-position
(rx/filter some?)
(rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt)
(rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt ms/mouse-position-mod)
(rx/map normalize-proportion-lock)
(rx/switch-map
(fn [[point _ _ :as current]]
(fn [[point _ _ _ :as current]]
(->> (snap/closest-snap-point page-id shapes objects layout zoom focus point)
(rx/map #(conj current %)))))
(rx/map #(resize shape initial-position layout objects %))
+28 -16
View File
@@ -119,31 +119,43 @@
(if (:fill-image value)
(let [uri (cf/resolve-file-media (:fill-image value))
keep-ar? (-> value :fill-image :keep-aspect-ratio)
tf (-> value :fill-image :transform)
img-x (if (some? tf) (* (get tf :x 0) width) 0)
img-y (if (some? tf) (* (get tf :y 0) height) 0)
img-w (if (some? tf) (* (get tf :width 1) width) width)
img-h (if (some? tf) (* (get tf :height 1) height) height)
image-props #js {:id (dm/str "fill-image-" render-id "-" fill-index)
:href (get embed uri uri)
:preserveAspectRatio (if keep-ar? "xMidYMid slice" "none")
:width width
:height height
:x img-x
:y img-y
:width img-w
:height img-h
:key (dm/str fill-index)
:opacity (:fill-opacity value)}]
[:> :image image-props])
[:> :rect props])))
(when ^boolean has-image?
[:g
;; We add this shape to add a padding so the patter won't repeat
;; Issue: https://tree.taiga.io/project/penpot/issue/5583
[:rect {:x 0
:y 0
:width (* width no-repeat-padding)
:height (* height no-repeat-padding)
:fill "none"}]
[:image {:href uri
:preserveAspectRatio "none"
:x 0
:y 0
:width width
:height height}]])]])])))
(let [tf (-> image :transform)
img-x (if (some? tf) (* (get tf :x 0) width) 0)
img-y (if (some? tf) (* (get tf :y 0) height) 0)
img-w (if (some? tf) (* (get tf :width 1) width) width)
img-h (if (some? tf) (* (get tf :height 1) height) height)]
[:g
;; We add this shape to add a padding so the patter won't repeat
;; Issue: https://tree.taiga.io/project/penpot/issue/5583
[:rect {:x 0
:y 0
:width (* width no-repeat-padding)
:height (* height no-repeat-padding)
:fill "none"}]
[:image {:href uri
:preserveAspectRatio "none"
:x img-x
:y img-y
:width img-w
:height img-h}]]))]])])))
(mf/defc fills
{::mf/wrap-props false}
@@ -95,6 +95,16 @@
[:span {:class (stl/css :icon)}
deprecated-icon/tick])]
[:li {:class (stl/css-case
:dropdown-element true
:selected (= :mentions cmode))
:data-value "mentions"
:on-click update-mode}
[:span {:class (stl/css :label)} (tr "labels.show-mentions")]
(when (= :mentions cmode)
[:span {:class (stl/css :icon)}
deprecated-icon/tick])]
[:li {:class (stl/css :separator)}]
[:li {:class (stl/css-case
@@ -4,6 +4,7 @@
//
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/_borders.scss" as *;
@use "refactor/common-refactor.scss" as deprecated;
// COMMENT DROPDOWN ON HEADER
@@ -92,7 +93,12 @@
}
.separator {
height: deprecated.$s-8;
position: relative;
block-size: var(--sp-xs);
inline-size: calc(100% + var(--sp-s));
border-top: $b-1 solid var(--color-background-quaternary);
left: calc(-1 * var(--sp-xs));
margin-top: var(--sp-s);
}
// FLOATING COMMENT
@@ -5,6 +5,7 @@
// Copyright (c) KALEIDOS SUBSIDIARY SL
@use "ds/_sizes.scss" as *;
@use "ds/_borders.scss" as *;
@use "refactor/common-refactor.scss" as deprecated;
.comments-section {
@@ -112,7 +113,12 @@
}
.separator {
height: deprecated.$s-12;
position: relative;
block-size: var(--sp-xs);
inline-size: calc(100% + var(--sp-s));
border-top: $b-1 solid var(--color-background-quaternary);
left: calc(-1 * var(--sp-xs));
margin-top: var(--sp-s);
}
.comments-section-content {
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.ui.workspace.viewport.path-state
(:require
@@ -0,0 +1,56 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns frontend-tests.data.comments-filters-test
(:require
[app.main.data.comments :as dcmt]
[app.util.storage :as storage]
[cljs.test :as t :include-macros true]
[potok.v2.core :as ptk]))
(def ^:private storage-key
:app.main.data.comments/hide-resolved-comments?)
(t/deftest test-merge-persisted-filters-default
(let [prev (get @storage/user storage-key)]
(try
(swap! storage/user dissoc storage-key)
(t/is (= {:show :all} (dcmt/merge-persisted-filters nil)))
(t/is (= {:show :all} (dcmt/merge-persisted-filters {})))
(finally
(if (some? prev)
(swap! storage/user assoc storage-key prev)
(swap! storage/user dissoc storage-key))))))
(t/deftest test-merge-persisted-filters-hide-resolved
(let [prev (get @storage/user storage-key)]
(try
(swap! storage/user assoc storage-key true)
(t/is (= {:show :pending} (dcmt/merge-persisted-filters nil)))
(finally
(if (some? prev)
(swap! storage/user assoc storage-key prev)
(swap! storage/user dissoc storage-key))))))
(t/deftest test-merge-persisted-filters-keeps-session-value
(let [prev (get @storage/user storage-key)]
(try
(swap! storage/user assoc storage-key true)
(t/is (= {:show :all :mode :yours}
(dcmt/merge-persisted-filters {:show :all :mode :yours})))
(finally
(if (some? prev)
(swap! storage/user assoc storage-key prev)
(swap! storage/user dissoc storage-key))))))
(t/deftest test-update-filters-updates-show
(let [event (dcmt/update-filters {:show :pending})
state (ptk/update event {})]
(t/is (= :pending (get-in state [:comments-local :show])))
(let [event (dcmt/update-filters {:show :all})
state (ptk/update event state)]
(t/is (= :all (get-in state [:comments-local :show]))))))
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-actions-test
(:require
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-clipboard-test
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-helpers-test
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-lifecycle-test
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-test-helpers
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.path-tools-test
@@ -802,3 +802,78 @@
(t/is (empty? (emit-of (mk {:nodes #{3} :segments #{} :handlers #{}})))))))
;; Path-local undo and redo events use a seeded local stack.
;; --- Handler type changes pick which handler keeps its geometry
(defn- aligned-uneven-handlers-content
"Returns content whose node (10,0) has aligned handlers at (8,0) and (16,0)."
[]
(path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to
:params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}}
{:command :curve-to
:params {:c1x 16 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}]))
(t/deftest making-handlers-equal-keeps-the-selected-handler-length
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (pth/selectable-path-state
id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}})
result (-> (ptk/update (path.tools/set-handler-type :mirror) state)
(path.state/get-path :content))]
;; The node is aligned with handlers of unequal length.
(t/is (= :aligned (path.helpers/derive-handler-type content 1)))
;; The selected handler keeps its length and the opposite one adapts.
(t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1)))
(t/is (= (gpt/point 4 0) (path/get-handler-point result 1 :c2)))))
(t/deftest making-handlers-equal-falls-back-to-the-last-edited-handler
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (-> (pth/selectable-path-state
id content {:nodes #{1} :segments #{} :handlers #{}})
(assoc-in [:workspace-local :edit-path id :edited-handler]
[2 :c1]))
result (-> (ptk/update (path.tools/set-handler-type :mirror) state)
(path.state/get-path :content))]
(t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1)))
(t/is (= (gpt/point 4 0) (path/get-handler-point result 1 :c2)))))
(t/deftest making-handlers-equal-uses-the-incoming-handler-with-no-hint
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (pth/selectable-path-state
id content {:nodes #{1} :segments #{} :handlers #{}})
result (-> (ptk/update (path.tools/set-handler-type :mirror) state)
(path.state/get-path :content))]
(t/is (= (gpt/point 8 0) (path/get-handler-point result 1 :c2)))
(t/is (= (gpt/point 12 0) (path/get-handler-point result 2 :c1)))))
(t/deftest aligning-handlers-takes-the-angle-of-the-selected-handler
(let [id (random-uuid)
content (path/content
[{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to
:params {:c1x 2 :c1y 0 :c2x 10 :c2y -3 :x 10 :y 0}}
{:command :curve-to
:params {:c1x 16 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}])
state (pth/selectable-path-state
id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}})
result (-> (ptk/update (path.tools/set-handler-type :aligned) state)
(path.state/get-path :content))]
;; The selected handler stays put; the opposite one rotates onto its axis
;; keeping its own length.
(t/is (= (gpt/point 16 0) (path/get-handler-point result 2 :c1)))
(t/is (= (gpt/point 7 0) (path/get-handler-point result 1 :c2)))))
(t/deftest dragging-a-handler-records-it-as-the-last-edited-one
(let [id (random-uuid)
content (aligned-uneven-handlers-content)
state (pth/selectable-path-state
id content {:nodes #{} :segments #{} :handlers #{[2 :c1]}})
state' (ptk/update
(path.edition/modify-selected-handlers id [2 :c1] {} 3 0 :independent false)
state)]
(t/is (= [2 :c1]
(get-in state' [:workspace-local :edit-path id :edited-handler])))))
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.wasm-modifiers-nil-id-test
"Reproduces the production crash \"Cannot read properties of null
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.plugins.flex-test
(:require
@@ -2,7 +2,7 @@
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.plugins.user-test
(:require
+2
View File
@@ -8,6 +8,7 @@
[frontend-tests.code-gen-style-test]
[frontend-tests.composable-tests.comp.sync-test]
[frontend-tests.copy-as-svg-test]
[frontend-tests.data.comments-filters-test]
[frontend-tests.data.dashboard-test]
[frontend-tests.data.exports-assets-test]
[frontend-tests.data.nitrate-test]
@@ -117,6 +118,7 @@
'frontend-tests.code-gen-style-test
'frontend-tests.composable-tests.comp.sync-test
'frontend-tests.copy-as-svg-test
'frontend-tests.data.comments-filters-test
'frontend-tests.data.dashboard-test
'frontend-tests.data.nitrate-test
'frontend-tests.data.profile-test
+8 -1
View File
@@ -124,14 +124,21 @@ macro_rules! with_current_shape {
#[cfg(test)]
pub(crate) struct TestRenderResourcesGuard {
prev: *mut RenderResources,
_lock: std::sync::MutexGuard<'static, ()>,
}
#[cfg(test)]
static TEST_RENDER_RESOURCES_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
impl TestRenderResourcesGuard {
pub(crate) fn install(resources: &mut RenderResources) -> Self {
let lock = TEST_RENDER_RESOURCES_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let prev = unsafe { RENDER_RESOURCES };
unsafe { RENDER_RESOURCES = resources as *mut _ };
Self { prev }
Self { prev, _lock: lock }
}
}
+316 -50
View File
@@ -268,7 +268,6 @@ fn difference(
.iter()
.filter(|s| path_a.contains(to_point(s.evaluate(TValue::Parametric(0.5)))))
.copied()
.map(|s| s.reverse())
.map(|b| (BezierSource::B, b)),
);
@@ -278,16 +277,53 @@ fn difference(
fn exclusion(segments_a: Vec<Bezier>, segments_b: Vec<Bezier>) -> Vec<(BezierSource, Bezier)> {
let mut result = Vec::new();
result.extend(segments_a.iter().copied().map(|b| (BezierSource::A, b)));
result.extend(
segments_b
.iter()
.copied()
.map(|s| s.reverse())
.map(|b| (BezierSource::B, b)),
);
result.extend(segments_b.iter().copied().map(|b| (BezierSource::B, b)));
result
}
// Mirrors `app.common.types.path.subpath/clockwise?`.
fn is_clockwise(path: &Path) -> bool {
let mut points: Vec<(f32, f32)> = Vec::new();
for segment in path.segments().iter() {
match *segment {
Segment::MoveTo(p) => {
if !points.is_empty() {
break;
}
points.push(p);
}
Segment::LineTo(p) => points.push(p),
Segment::CurveTo((_, _, p)) => points.push(p),
Segment::Close => break,
}
}
if points.len() < 3 {
return false;
}
let mut signed_area = 0.0f64;
for i in 0..points.len() {
let (x1, y1) = points[i];
let (x2, y2) = points[(i + 1) % points.len()];
signed_area += f64::from(x1) * f64::from(y2) - f64::from(x2) * f64::from(y1);
}
signed_area > 0.0
}
// The kept pieces of B must point the same way round as the kept pieces of A. Not the
// `path.bool/content-bool-pair` rule, which reverses intersection on same winding and
// relies on `subpath/merge-paths` flipping subpaths when it joins them.
fn should_reverse_b(bool_type: BoolType, a_is_clockwise: bool, path_b: &Path) -> bool {
let same_winding = a_is_clockwise == is_clockwise(path_b);
match bool_type {
BoolType::Union | BoolType::Intersection => !same_winding,
BoolType::Difference | BoolType::Exclusion => same_winding,
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
enum BezierSource {
A,
@@ -305,17 +341,14 @@ fn pop_first_from_pool(pool: &mut BezierPool) -> Option<(BezierSource, Bezier)>
pool.iter_mut().find_map(|e| e.take())
}
// Find and remove the segment whose start point is closest to `end` within the
// appropriate threshold. Same-source segments use a tight threshold
// (INTERSECT_THRESHOLD_SAMEd) so we prefer staying on the same original path;
// cross-source segments use a wider threshold (INTERSECT_THRESHOLD_DIFFERENT)
// to allow switching paths at intersection points.
// Same-source candidates get a tighter threshold so we stay on the original path. A
// candidate that joins by its `end` points the wrong way, so we reverse it.
fn find_next_in_pool(
pool: &mut BezierPool,
end: DVec2,
source: BezierSource,
) -> Option<(BezierSource, Bezier)> {
let mut best_idx: Option<usize> = None;
let mut best: Option<(usize, bool)> = None;
let mut best_dist_sq = f64::MAX;
for (i, entry) in pool.iter().enumerate() {
@@ -327,16 +360,21 @@ fn find_next_in_pool(
} else {
INTERSECT_THRESHOLD_DIFFERENT as f64
};
let dx = bezier.start.x - end.x;
let dy = bezier.start.y - end.y;
let dist_sq = dx * dx + dy * dy;
if dist_sq <= threshold * threshold && dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best_idx = Some(i);
for (reversed, point) in [(false, bezier.start), (true, bezier.end)] {
let dx = point.x - end.x;
let dy = point.y - end.y;
let dist_sq = dx * dx + dy * dy;
if dist_sq <= threshold * threshold && dist_sq < best_dist_sq {
best_dist_sq = dist_sq;
best = Some((i, reversed));
}
}
}
best_idx.and_then(|i| pool[i].take())
let (idx, reversed) = best?;
pool[idx]
.take()
.map(|(src, bezier)| (src, if reversed { bezier.reverse() } else { bezier }))
}
fn push_bezier(result: &mut Vec<Segment>, bezier: &Bezier) {
@@ -410,32 +448,45 @@ fn beziers_to_segments(beziers: &[(BezierSource, Bezier)]) -> Vec<Segment> {
result
}
pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: ShapesPoolRef) -> Path {
if children_ids.is_empty() {
return Path::default();
fn bool_beziers(
bool_type: BoolType,
path_a: &Path,
a_is_clockwise: bool,
path_b: &Path,
) -> (Vec<(BezierSource, Bezier)>, bool) {
let (segs_a, mut segs_b) = split_segments(path_a, path_b);
if should_reverse_b(bool_type, a_is_clockwise, path_b) {
for segment in segs_b.iter_mut() {
*segment = segment.reverse();
}
}
let Some(child) = shapes.get(&children_ids[children_ids.len() - 1]) else {
let beziers = match bool_type {
BoolType::Union => union(path_a, segs_a, path_b, segs_b),
BoolType::Difference => difference(path_a, segs_a, path_b, segs_b),
BoolType::Intersection => intersection(path_a, segs_a, path_b, segs_b),
BoolType::Exclusion => exclusion(segs_a, segs_b),
};
(beziers, path_a.is_even_odd() || path_b.is_even_odd())
}
// Fold `paths` left to right; the first entry is the base operand.
fn bool_fold(bool_type: BoolType, paths: &[Path]) -> Path {
let Some((first, rest)) = paths.split_first() else {
return Path::default();
};
let mut current_path = child.to_path(shapes);
let mut current_path = first.clone();
// Every fold step chains A's fragments, which keep their direction, so the
// accumulated path keeps this winding. Carry it instead of re-reading it from the
// emitted segment list, whose subpath order and direction fall out of pool ordering.
let is_clockwise_a = is_clockwise(&current_path);
for idx in (0..children_ids.len() - 1).rev() {
let Some(other) = shapes.get(&children_ids[idx]) else {
continue;
};
let other_path = other.to_path(shapes);
let (segs_a, segs_b) = split_segments(&current_path, &other_path);
let is_even_odd = current_path.is_even_odd() || other_path.is_even_odd();
let beziers = match bool_type {
BoolType::Union => union(&current_path, segs_a, &other_path, segs_b),
BoolType::Difference => difference(&current_path, segs_a, &other_path, segs_b),
BoolType::Intersection => intersection(&current_path, segs_a, &other_path, segs_b),
BoolType::Exclusion => exclusion(segs_a, segs_b),
};
for other_path in rest {
let (beziers, is_even_odd) =
bool_beziers(bool_type, &current_path, is_clockwise_a, other_path);
current_path = Path::new(beziers_to_segments(&beziers)).with_even_odd(is_even_odd);
}
@@ -443,6 +494,16 @@ pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: Shap
current_path
}
pub fn bool_from_shapes(bool_type: BoolType, children_ids: &[Uuid], shapes: ShapesPoolRef) -> Path {
let paths: Vec<Path> = children_ids
.iter()
.rev()
.filter_map(|id| shapes.get(id).map(|child| child.to_path(shapes)))
.collect();
bool_fold(bool_type, &paths)
}
pub fn update_bool_to_path(shape: &mut Shape, shapes: ShapesPoolRef) {
let children_ids = shape.children_ids(true);
@@ -481,6 +542,7 @@ pub fn debug_render_bool_paths(
};
let mut current_path = child.to_path(shapes);
let is_clockwise_a = is_clockwise(&current_path);
for idx in (0..children_ids.len() - 1).rev() {
let Some(other) = shapes.get(&children_ids[idx]) else {
@@ -488,15 +550,12 @@ pub fn debug_render_bool_paths(
};
let other_path = other.to_path(shapes);
let (segs_a, segs_b) = split_segments(&current_path, &other_path);
let is_even_odd = current_path.is_even_odd() || other_path.is_even_odd();
let beziers = match bool_data.bool_type {
BoolType::Union => union(&current_path, segs_a, &other_path, segs_b),
BoolType::Difference => difference(&current_path, segs_a, &other_path, segs_b),
BoolType::Intersection => intersection(&current_path, segs_a, &other_path, segs_b),
BoolType::Exclusion => exclusion(segs_a, segs_b),
};
let (beziers, is_even_odd) = bool_beziers(
bool_data.bool_type,
&current_path,
is_clockwise_a,
&other_path,
);
current_path = Path::new(beziers_to_segments(&beziers)).with_even_odd(is_even_odd);
if idx == 0 {
@@ -572,3 +631,210 @@ pub fn debug_render_bool_paths(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn linear(from: (f64, f64), to: (f64, f64)) -> Bezier {
Bezier::from_linear_coordinates(from.0, from.1, to.0, to.1)
}
fn polygon(points: &[(f32, f32)]) -> Path {
let mut segments = vec![Segment::MoveTo(points[0])];
segments.extend(points[1..].iter().map(|p| Segment::LineTo(*p)));
segments.push(Segment::Close);
Path::new(segments)
}
fn count(segments: &[Segment], f: fn(&Segment) -> bool) -> usize {
segments.iter().filter(|s| f(s)).count()
}
fn is_move_to(s: &Segment) -> bool {
matches!(s, Segment::MoveTo(_))
}
fn is_close(s: &Segment) -> bool {
matches!(s, Segment::Close)
}
fn ring_area(ring: &[(f32, f32)]) -> f64 {
let mut area = 0.0f64;
for i in 0..ring.len() {
let (x1, y1) = ring[i];
let (x2, y2) = ring[(i + 1) % ring.len()];
area += f64::from(x1) * f64::from(y2) - f64::from(x2) * f64::from(y1);
}
area / 2.0
}
fn signed_area(segments: &[Segment]) -> f64 {
let mut total = 0.0f64;
let mut ring: Vec<(f32, f32)> = Vec::new();
for segment in segments {
match *segment {
Segment::MoveTo(p) => {
total += ring_area(&ring);
ring.clear();
ring.push(p);
}
Segment::LineTo(p) | Segment::CurveTo((_, _, p)) => ring.push(p),
Segment::Close => {
total += ring_area(&ring);
ring.clear();
}
}
}
total + ring_area(&ring)
}
// Operands and expected results taken from the CLJS bool (`app.common.types.path.bool`)
// run on the same shapes: A clockwise, B and C counter-clockwise, all overlapping.
const A_CW: [(f32, f32); 4] = [
(100.0, 100.0),
(300.0, 100.0),
(300.0, 300.0),
(100.0, 300.0),
];
const B_CCW: [(f32, f32); 4] = [
(200.0, 200.0),
(200.0, 400.0),
(400.0, 400.0),
(400.0, 200.0),
];
const C_CCW: [(f32, f32); 4] = [(60.0, 240.0), (60.0, 360.0), (260.0, 360.0), (260.0, 240.0)];
#[test]
fn test_is_clockwise() {
let cw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((10.0, 0.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((0.0, 10.0)),
Segment::Close,
]);
assert!(is_clockwise(&cw));
let ccw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((0.0, 10.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((10.0, 0.0)),
Segment::Close,
]);
assert!(!is_clockwise(&ccw));
}
#[test]
fn test_should_reverse_b_only_depends_on_relative_winding() {
let cw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((10.0, 0.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((0.0, 10.0)),
Segment::Close,
]);
let ccw = Path::new(vec![
Segment::MoveTo((0.0, 0.0)),
Segment::LineTo((0.0, 10.0)),
Segment::LineTo((10.0, 10.0)),
Segment::LineTo((10.0, 0.0)),
Segment::Close,
]);
assert!(should_reverse_b(BoolType::Difference, true, &cw));
assert!(!should_reverse_b(BoolType::Difference, true, &ccw));
assert!(!should_reverse_b(BoolType::Union, true, &cw));
assert!(should_reverse_b(BoolType::Union, true, &ccw));
}
// Fragments from #11482: two point the wrong way, so joining them start-to-start only
// left five open subpaths.
#[test]
fn test_beziers_to_segments_closes_reversed_fragments() {
let beziers = vec![
(
BezierSource::A,
linear((2764.00, -240.00), (2834.74, -110.71)),
),
(
BezierSource::A,
linear((2809.29, -85.26), (2693.26, -201.29)),
),
(
BezierSource::A,
linear((2718.71, -226.74), (2764.00, -240.00)),
),
(
BezierSource::B,
linear((2718.71, -226.74), (2834.74, -110.71)),
),
(
BezierSource::B,
linear((2809.29, -85.26), (2693.26, -201.29)),
),
];
let segments = beziers_to_segments(&beziers);
let moves = segments
.iter()
.filter(|s| matches!(s, Segment::MoveTo(_)))
.count();
let closes = segments
.iter()
.filter(|s| matches!(s, Segment::Close))
.count();
assert_eq!(moves, 2);
assert_eq!(closes, 2);
// 3 fragments in the first subpath, 2 in the second, each dropping its closing LineTo.
assert_eq!(segments.len(), 7);
}
// #11482: A clockwise, B counter-clockwise. Reference (CLJS):
// M100,100 L300,100 L300,200 L200,200 L200,300 L100,300 Z
#[test]
fn test_difference_with_opposite_winding_operand() {
let result = bool_fold(BoolType::Difference, &[polygon(&A_CW), polygon(&B_CCW)]);
let segments = result.segments();
assert_eq!(count(segments, is_move_to), 1);
assert_eq!(count(segments, is_close), 1);
assert!((signed_area(segments) - 30000.0).abs() < 1.0);
assert!(is_clockwise(&result));
}
// Reference (CLJS):
// M100,100 L300,100 L300,200 L400,200 L400,400 L200,400 L200,300 L100,300 Z
#[test]
fn test_union_with_opposite_winding_operand() {
let result = bool_fold(BoolType::Union, &[polygon(&A_CW), polygon(&B_CCW)]);
let segments = result.segments();
assert_eq!(count(segments, is_move_to), 1);
assert_eq!(count(segments, is_close), 1);
assert!((signed_area(segments) - 70000.0).abs() < 1.0);
assert!(is_clockwise(&result));
}
// The second fold step must compare against A's winding, not against the winding of
// the intermediate path, whose subpath order and direction fall out of pool ordering.
// Reference (CLJS): M100,100 L300,100 L300,200 L200,200 L200,240 L100,240 Z
#[test]
fn test_difference_folds_three_opposite_winding_operands() {
let result = bool_fold(
BoolType::Difference,
&[polygon(&A_CW), polygon(&B_CCW), polygon(&C_CCW)],
);
let segments = result.segments();
assert_eq!(count(segments, is_move_to), 1);
assert_eq!(count(segments, is_close), 1);
assert!((signed_area(segments) - 24000.0).abs() < 1.0);
assert!(is_clockwise(&result));
}
}
+19 -16
View File
@@ -3,7 +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::render::get_source_rect;
use crate::render::{get_image_dest_rect, get_source_rect};
use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type};
// Set the clipping area to the shape outline within the container bounds
@@ -91,11 +91,12 @@ 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 = get_image_dest_rect(container, image_fill);
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 +122,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 +164,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 +180,22 @@ 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 = get_image_dest_rect(container, image_fill);
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();
+12
View File
@@ -20,6 +20,18 @@ pub fn get_dest_rect(container: &MathRect, delta: f32) -> MathRect {
)
}
pub fn get_image_dest_rect(container: &MathRect, image_fill: &ImageFill) -> MathRect {
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,
}
}
pub fn get_source_rect(size: ISize, container: &MathRect, image_fill: &ImageFill) -> MathRect {
let image_width = size.width as f32;
let image_height = size.height as f32;
+10 -8
View File
@@ -5,7 +5,8 @@ use crate::shapes::{Fill, ImageFill, Shape};
use crate::state::ShapesPoolRef;
use super::document::SvgLayerCanvas;
use crate::render::RenderResources;
use crate::math::Rect as MathRect;
use crate::render::{get_image_dest_rect, RenderResources};
/// Emits fills bottom -> top for SVG export.
///
@@ -63,19 +64,20 @@ fn emit_image_fill(
let clip_id = builder.unique("imgclip");
builder.push_clip_path(&clip_id, shape, tree);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
let dest_rect = get_image_dest_rect(&shape.selrect(), image_fill);
emit_linked_image_element(builder, shape, image_fill, dest_rect, &href, &clip_id);
Ok(())
}
/// Emits `<g clip-path>` + `<image href>` using the shape selrect and page CTM.
/// Emits `<g clip-path>` + `<image href>` at `dest_rect`, under the page CTM.
pub(super) fn emit_linked_image_element(
builder: &mut SvgLayerCanvas,
shape: &Shape,
image_fill: &ImageFill,
dest_rect: MathRect,
href: &str,
clip_id: &str,
) {
let selrect = shape.selrect();
let opacity = image_fill.opacity() as f32 / 255.0;
let preserve = if image_fill.keep_aspect_ratio() {
"xMidYMid slice"
@@ -93,10 +95,10 @@ pub(super) fn emit_linked_image_element(
builder.open_group(&format!("clip-path=\"url(#{clip_id})\""));
builder.push_raw(&format!(
r#"<image href="{href}" x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="{preserve}"{opacity_attr} transform="{transform}"/>"#,
selrect.left(),
selrect.top(),
selrect.width(),
selrect.height(),
dest_rect.left(),
dest_rect.top(),
dest_rect.width(),
dest_rect.height(),
));
builder.close_group();
}
+40 -1
View File
@@ -1,6 +1,8 @@
use super::fixtures::*;
use crate::shapes::{BlendMode, Fill, ImageFill, SolidColor, StrokeCap, StrokeKind};
use crate::shapes::{
BlendMode, Fill, ImageFill, ImageFillTransform, SolidColor, StrokeCap, StrokeKind,
};
use crate::state::ShapesPool;
use crate::uuid::Uuid;
@@ -1076,6 +1078,43 @@ fn exports_image_fill_as_linked_image() {
insta::assert_snapshot!(svg);
}
#[test]
fn exports_image_fill_bounds_transform() {
let mut pool = ShapesPool::new();
let id = uid(1);
let image_id = uid(42);
add_rect_with_fills(
&mut pool,
id,
Uuid::nil(),
(0.0, 0.0, 100.0, 80.0),
vec![Fill::Image(ImageFill::new_with_transform(
image_id,
255,
200,
100,
false,
Some(ImageFillTransform {
x: 0.25,
y: 0.5,
width: 0.5,
height: 0.25,
}),
))],
);
let svg = render_with(&pool, id, |resources| {
resources
.images
.set_source_url(image_id, TEST_IMAGE_URL.to_string());
});
assert!(
svg.contains(r#"x="25" y="40" width="50" height="20""#),
"linked image must keep the independent image bounds: {svg}"
);
}
#[test]
fn exports_mixed_solid_and_image_fills_in_order() {
// Image under a translucent solid; stretch (keep-aspect off); partial image
+1 -1
View File
@@ -117,6 +117,6 @@ fn emit_text_image_fill(
builder.finish_clip_path_fragment(&clip_id, canvas);
let href = xml_escape_attr(url);
emit_linked_image_element(builder, shape, image_fill, &href, &clip_id);
emit_linked_image_element(builder, shape, image_fill, shape.selrect(), &href, &clip_id);
Ok(())
}
+215 -115
View File
@@ -3,15 +3,16 @@ use crate::{
error::Result,
math::Rect,
shapes::{
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup,
ParagraphLayout, Stroke, StrokeKind, TextContent, VerticalAlign,
add_text_with_tabs, calculate_text_layout_data, set_paint_fill, Paragraph as TextParagraph,
ParagraphBuilderGroup, ParagraphLayout, Stroke, StrokeKind, TextContent,
TextDecorationSegment, VerticalAlign,
},
utils::{get_fallback_fonts, get_font_collection},
};
use skia_safe::{
self as skia,
canvas::SaveLayerRec,
textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration, TextStyle},
textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration},
Canvas, ImageFilter, Paint,
};
@@ -374,85 +375,22 @@ fn paint_from_cached_layout(canvas: &Canvas, shape: &Shape, text_content: &TextC
};
let mut y_accum = base_y + vertical_offset;
for group in paragraphs.iter() {
for (index, group) in paragraphs.iter().enumerate() {
let Some(paragraph) = group.first() else {
continue;
};
paragraph.paint(canvas, (x, y_accum));
if draw_decorations {
paint_decorations_for_paragraph(canvas, paragraph, x, y_accum);
if let Some(text_paragraph) = text_content.paragraphs().get(index) {
for deco in decoration_segments(paragraph, text_paragraph, x, y_accum) {
draw_decoration_segment(canvas, &deco);
}
}
}
y_accum += paragraph.height();
}
}
fn paint_decorations_for_paragraph(
canvas: &Canvas,
paragraph: &skia::textlayout::Paragraph,
x: f32,
y_accum: f32,
) {
let line_metrics = paragraph.get_line_metrics();
for line in &line_metrics {
let style_metrics: Vec<_> = line
.get_style_metrics(line.start_index..line.end_index)
.into_iter()
.collect();
let line_baseline = y_accum + line.baseline as f32;
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
calculate_decoration_metrics(&style_metrics, line_baseline);
for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() {
let text_style = &style_metric.text_style;
let style_end = style_metrics
.get(i + 1)
.map(|(next_i, _)| *next_i)
.unwrap_or(line.end_index);
let seg_start = (*style_start).max(line.start_index);
let seg_end = style_end.min(line.end_index);
if seg_start >= seg_end {
continue;
}
let rects = paragraph.get_rects_for_range(
seg_start..seg_end,
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::RectWidthStyle::Tight,
);
let (segment_width, actual_x_offset) = if !rects.is_empty() {
let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
let skia_x_offset = rects
.first()
.map(|r| r.rect.left - line.left as f32)
.unwrap_or(0.0);
(total_width, skia_x_offset)
} else {
(0.0, 0.0)
};
let text_left = x + line.left as f32 + actual_x_offset;
let text_width = segment_width;
if text_style.decoration().ty == TextDecoration::UNDERLINE {
draw_text_decorations(
canvas,
text_style,
Some(underline_y.unwrap_or(line_baseline)),
max_underline_thickness,
text_left,
text_width,
);
}
if text_style.decoration().ty == TextDecoration::LINE_THROUGH {
draw_text_decorations(
canvas,
text_style,
Some(strike_y.unwrap_or(line_baseline)),
max_strike_thickness,
text_left,
text_width,
);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_text_on_canvas(
canvas: &Canvas,
@@ -584,14 +522,7 @@ fn paint_text_with_emoji_overlay(
}
for deco in &para.decorations {
draw_text_decorations(
canvas,
&deco.text_style,
Some(deco.y),
deco.thickness,
deco.left,
deco.width,
);
draw_decoration_segment(canvas, deco);
}
}
}
@@ -811,17 +742,9 @@ fn paint_emoji_opaque(
.paint(canvas, (emoji_para.x, emoji_para.y));
for deco in &deco_para.decorations {
draw_text_decorations(
canvas,
&deco.text_style,
Some(deco.y),
deco.thickness,
deco.left,
deco.width,
);
let r = decoration_rect(deco.y, deco.thickness, deco.left, deco.width);
draw_decoration_segment(canvas, deco);
for (kind, paint) in stroke_decos {
draw_decoration_stroke(canvas, *kind, paint, r);
draw_decoration_stroke(canvas, *kind, paint, deco.rect());
}
}
canvas.restore();
@@ -1134,40 +1057,128 @@ pub fn render_outer_stroke(
)
}
fn decoration_rect(y: f32, thickness: f32, text_left: f32, text_width: f32) -> skia_safe::Rect {
skia_safe::Rect::new(
text_left,
y - thickness / 2.0,
text_left + text_width,
y + thickness / 2.0,
)
fn draw_decoration_segment(canvas: &Canvas, deco: &TextDecorationSegment) {
let mut decoration_paint = deco.text_style.foreground();
decoration_paint.set_anti_alias(true);
canvas.draw_rect(deco.rect(), &decoration_paint);
}
fn draw_text_decorations(
canvas: &Canvas,
text_style: &TextStyle,
y: Option<f32>,
thickness: f32,
text_left: f32,
text_width: f32,
) {
if let Some(y) = y {
let r = decoration_rect(y, thickness, text_left, text_width);
let mut decoration_paint = text_style.foreground();
decoration_paint.set_anti_alias(true);
canvas.draw_rect(r, &decoration_paint);
/// One decorated span clipped to a line: UTF-16 range, decoration and the
/// Skia style run it falls in (paint + font metrics).
type LineDecoration<'a> = (usize, usize, TextDecoration, &'a StyleMetrics<'a>);
/// UTF-16 ranges of the spans that ask for a decoration we draw.
fn decorated_span_ranges(text_paragraph: &TextParagraph) -> Vec<(usize, usize, TextDecoration)> {
let mut ranges = Vec::new();
let mut offset = 0;
for span in text_paragraph.children() {
let len = span.apply_text_transform().encode_utf16().count();
match span.text_decoration {
Some(kind)
if kind == TextDecoration::UNDERLINE || kind == TextDecoration::LINE_THROUGH =>
{
ranges.push((offset, offset + len, kind))
}
_ => {}
}
offset += len;
}
ranges
}
pub fn calculate_decoration_metrics(
style_metrics: &Vec<(usize, &StyleMetrics)>,
/// Style run covering `offset`; runs are keyed by their start index.
fn style_metric_at<'a>(
style_metrics: &[(usize, &'a StyleMetrics<'a>)],
offset: usize,
) -> Option<&'a StyleMetrics<'a>> {
style_metrics
.iter()
.rev()
.find(|(start, _)| *start <= offset)
.map(|(_, metrics)| *metrics)
}
/// Decoration bars for one laid out paragraph, in shape coordinates.
///
/// Segmented by the model's spans, so which spans get a bar never depends on
/// how Skia grouped the line into style runs; the runs only supply the paint
/// and font metrics covering each segment.
pub fn decoration_segments(
skia_paragraph: &skia::textlayout::Paragraph,
text_paragraph: &TextParagraph,
x: f32,
y_accum: f32,
) -> Vec<TextDecorationSegment> {
let decorated = decorated_span_ranges(text_paragraph);
if decorated.is_empty() {
return Vec::new();
}
let mut segments = Vec::new();
for line in &skia_paragraph.get_line_metrics() {
let style_metrics: Vec<_> = line
.get_style_metrics(line.start_index..line.end_index)
.into_iter()
.collect();
let line_baseline = y_accum + line.baseline as f32;
let line_decorations: Vec<LineDecoration<'_>> = decorated
.iter()
.filter_map(|&(start, end, kind)| {
let seg_start = start.max(line.start_index);
let seg_end = end.min(line.end_index);
if seg_start >= seg_end {
return None;
}
let metrics = style_metric_at(&style_metrics, seg_start)?;
Some((seg_start, seg_end, kind, metrics))
})
.collect();
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
calculate_decoration_metrics(&line_decorations, line_baseline);
for (seg_start, seg_end, kind, metrics) in line_decorations {
let rects = skia_paragraph.get_rects_for_range(
seg_start..seg_end,
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::RectWidthStyle::Tight,
);
let (width, x_offset) = match rects.first() {
Some(first) => {
let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
(total_width, first.rect.left - line.left as f32)
}
None => (0.0, 0.0),
};
let (y, thickness) = if kind == TextDecoration::LINE_THROUGH {
(strike_y, max_strike_thickness)
} else {
(underline_y, max_underline_thickness)
};
segments.push(TextDecorationSegment {
kind,
text_style: (*metrics.text_style).clone(),
y: y.unwrap_or(line_baseline),
thickness,
left: x + line.left as f32 + x_offset,
width,
});
}
}
segments
}
fn calculate_decoration_metrics(
line_decorations: &[LineDecoration<'_>],
line_baseline: f32,
) -> (f32, Option<f32>, f32, Option<f32>) {
let mut max_underline_thickness: f32 = 0.0;
let mut underline_y = None;
let mut max_strike_thickness: f32 = 0.0;
let mut strike_y = None;
for (_style_start, style_metric) in style_metrics.iter() {
for (_seg_start, _seg_end, kind, style_metric) in line_decorations.iter() {
let font_metrics = style_metric.font_metrics;
let font_size = font_metrics
.cap_height
@@ -1183,7 +1194,7 @@ pub fn calculate_decoration_metrics(
let thickness = (font_metrics.underline_thickness().unwrap_or(1.0) * thickness_factor)
.max(min_thickness);
if style_metric.text_style.decoration().ty == TextDecoration::UNDERLINE {
if *kind == TextDecoration::UNDERLINE {
// Same gap from baseline to underline as in Chromium
// (see https://source.chromium.org/chromium/chromium/src/+/main:ui/gfx/render_text.cc
let gap_scaling = raw_font_size * 1.0 / 9.0;
@@ -1192,7 +1203,7 @@ pub fn calculate_decoration_metrics(
max_underline_thickness = max_underline_thickness.max(thickness);
underline_y = Some(y);
}
if style_metric.text_style.decoration().ty == TextDecoration::LINE_THROUGH {
if *kind == TextDecoration::LINE_THROUGH {
let y = line_baseline
+ font_metrics
.strikeout_position()
@@ -1234,3 +1245,92 @@ pub fn calculate_decoration_metrics(
// shadows::render_text_inner_shadows(self, &shape, &paths, antialias);
// }
#[cfg(test)]
mod tests {
use super::*;
use crate::shapes::{FontFamily, FontStyle, TextAlign, TextDirection, TextSpan, TextTransform};
use crate::uuid::Uuid;
fn span(
text: &str,
decoration: Option<TextDecoration>,
transform: Option<TextTransform>,
) -> TextSpan {
TextSpan::new(
text.to_string(),
FontFamily::new(Uuid::nil(), 400, FontStyle::Normal),
14.0,
1.2,
0.0,
decoration,
transform,
TextDirection::LTR,
400,
Uuid::nil(),
vec![],
)
}
fn paragraph(spans: Vec<TextSpan>) -> TextParagraph {
TextParagraph::new(
TextAlign::Left,
TextDirection::LTR,
None,
None,
1.2,
0.0,
spans,
)
}
#[test]
fn decorated_ranges_follow_spans_not_paint_runs() {
let para = paragraph(vec![
span("plain ", None, None),
span("under", Some(TextDecoration::UNDERLINE), None),
span(" plain ", None, None),
span("struck", Some(TextDecoration::LINE_THROUGH), None),
]);
assert_eq!(
decorated_span_ranges(&para),
vec![
(6, 11, TextDecoration::UNDERLINE),
(18, 24, TextDecoration::LINE_THROUGH),
]
);
}
#[test]
fn decorated_ranges_are_utf16_offsets_of_the_transformed_text() {
let para = paragraph(vec![
span("🎉", None, None),
span(
"straße",
Some(TextDecoration::UNDERLINE),
Some(TextTransform::Uppercase),
),
span("x", Some(TextDecoration::UNDERLINE), None),
]);
// The emoji takes two UTF-16 units and `ß` uppercases to `SS`.
assert_eq!(
decorated_span_ranges(&para),
vec![
(2, 9, TextDecoration::UNDERLINE),
(9, 10, TextDecoration::UNDERLINE),
]
);
}
#[test]
fn undecorated_paragraphs_have_no_ranges() {
let para = paragraph(vec![
span("plain", None, None),
span("none", Some(TextDecoration::NO_DECORATION), None),
]);
assert!(decorated_span_ranges(&para).is_empty());
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ use super::shape_renderer::ShapeRenderer;
use super::text;
use super::RenderResources;
use super::RenderState;
use super::{get_dest_rect, get_source_rect};
use super::{get_dest_rect, get_image_dest_rect, get_source_rect};
// ---------------------------------------------------------------------------
// VectorRenderer — implements ShapeRenderer for canvas-based vector export
@@ -1043,8 +1043,8 @@ fn draw_image_fill(
let size = image.dimensions();
let container = &shape.selrect;
let src_rect = get_source_rect(size, container, image_fill);
let dest_rect = container;
let dest_rect = get_image_dest_rect(container, image_fill);
let src_rect = get_source_rect(size, &dest_rect, image_fill);
canvas.save();
+32
View File
@@ -118,6 +118,14 @@ impl Gradient {
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
pub struct ImageFillTransform {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImageFill {
id: Uuid,
@@ -125,6 +133,7 @@ pub struct ImageFill {
width: i32,
height: i32,
keep_aspect_ratio: bool,
transform: Option<ImageFillTransform>,
}
impl ImageFill {
@@ -135,6 +144,25 @@ impl ImageFill {
width,
height,
keep_aspect_ratio,
transform: None,
}
}
pub fn new_with_transform(
id: Uuid,
opacity: u8,
width: i32,
height: i32,
keep_aspect_ratio: bool,
transform: Option<ImageFillTransform>,
) -> Self {
Self {
id,
opacity,
width,
height,
keep_aspect_ratio,
transform,
}
}
@@ -157,6 +185,10 @@ impl ImageFill {
pub fn height(&self) -> i32 {
self.height
}
pub fn transform(&self) -> Option<&ImageFillTransform> {
self.transform.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Copy)]
+19 -63
View File
@@ -1,4 +1,4 @@
use crate::render::text::calculate_decoration_metrics;
use crate::render::text::decoration_segments;
use crate::{
math::{Bounds, Matrix, Rect},
render::{default_font, DEFAULT_EMOJI_FONT},
@@ -320,6 +320,18 @@ pub struct TextDecorationSegment {
pub width: f32,
}
impl TextDecorationSegment {
/// The bar to paint, centered on `y`.
pub fn rect(&self) -> Rect {
Rect::new(
self.left,
self.y - self.thickness / 2.0,
self.left + self.width,
self.y + self.thickness / 2.0,
)
}
}
fn vertical_align_offset(container_h: f32, content_h: f32, valign: VerticalAlign) -> f32 {
match valign {
VerticalAlign::Center => (container_h - content_h) / 2.0,
@@ -1825,68 +1837,12 @@ pub fn calculate_text_layout_data(
for (i, group_paragraphs) in built_groups.into_iter().enumerate() {
// For each paragraph in the group (e.g., fill, stroke, etc.)
for skia_paragraph in group_paragraphs.into_iter() {
// Calculate text decorations for this paragraph
let mut decorations = Vec::new();
let line_metrics = skia_paragraph.get_line_metrics();
for line in &line_metrics {
let style_metrics: Vec<_> = line
.get_style_metrics(line.start_index..line.end_index)
.into_iter()
.collect();
let line_baseline = y_accum + line.baseline as f32;
let (max_underline_thickness, underline_y, max_strike_thickness, strike_y) =
calculate_decoration_metrics(&style_metrics, line_baseline);
for (i, (style_start, style_metric)) in style_metrics.iter().enumerate() {
let text_style = &style_metric.text_style;
let style_end = style_metrics
.get(i + 1)
.map(|(next_i, _)| *next_i)
.unwrap_or(line.end_index);
let seg_start = (*style_start).max(line.start_index);
let seg_end = style_end.min(line.end_index);
if seg_start >= seg_end {
continue;
}
let rects = skia_paragraph.get_rects_for_range(
seg_start..seg_end,
skia::textlayout::RectHeightStyle::Tight,
skia::textlayout::RectWidthStyle::Tight,
);
let (segment_width, actual_x_offset) = if !rects.is_empty() {
let total_width: f32 = rects.iter().map(|r| r.rect.width()).sum();
let skia_x_offset = rects
.first()
.map(|r| r.rect.left - line.left as f32)
.unwrap_or(0.0);
(total_width, skia_x_offset)
} else {
(0.0, 0.0)
};
let text_left = x + line.left as f32 + actual_x_offset;
let text_width = segment_width;
use skia::textlayout::TextDecoration;
if text_style.decoration().ty == TextDecoration::UNDERLINE {
decorations.push(TextDecorationSegment {
kind: TextDecoration::UNDERLINE,
text_style: (*text_style).clone(),
y: underline_y.unwrap_or(line_baseline),
thickness: max_underline_thickness,
left: text_left,
width: text_width,
});
}
if text_style.decoration().ty == TextDecoration::LINE_THROUGH {
decorations.push(TextDecorationSegment {
kind: TextDecoration::LINE_THROUGH,
text_style: (*text_style).clone(),
y: strike_y.unwrap_or(line_baseline),
thickness: max_strike_thickness,
left: text_left,
width: text_width,
});
}
}
}
let decorations = text_paragraphs
.get(i)
.map(|text_paragraph| {
decoration_segments(&skia_paragraph, text_paragraph, x, y_accum)
})
.unwrap_or_default();
paragraph_layouts.push(ParagraphLayout {
paragraph: skia_paragraph,
x,
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more