Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7861631058 | ||
|
|
9b24907992 | ||
|
|
6f63a5fcbf | ||
|
|
61caefbcf3 | ||
|
|
6586631293 | ||
|
|
66fb4a69ba | ||
|
|
3d4a5ca2aa | ||
|
|
d45c6710b7 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,6 +72,13 @@
|
||||
[:map {:title "PlainColorAttrs"}
|
||||
[:color schema:hex-color]])
|
||||
|
||||
(def schema:image-transform
|
||||
[:map {:title "ImageTransform" :closed true}
|
||||
[:x {:optional true} ::sm/safe-number]
|
||||
[:y {:optional true} ::sm/safe-number]
|
||||
[:width {:optional true} ::sm/safe-number]
|
||||
[:height {:optional true} ::sm/safe-number]])
|
||||
|
||||
(def schema:image
|
||||
[:map {:title "ImageColor" :closed true}
|
||||
[:width [::sm/int {:min 0 :gen/gen sg/int}]]
|
||||
@@ -79,7 +86,8 @@
|
||||
[:mtype {:gen/gen (sg/elements cm/image-types)} ::sm/text]
|
||||
[:id ::sm/uuid]
|
||||
[:name {:optional true} ::sm/text]
|
||||
[:keep-aspect-ratio {:optional true} :boolean]])
|
||||
[:keep-aspect-ratio {:optional true} :boolean]
|
||||
[:transform {:optional true} schema:image-transform]])
|
||||
|
||||
(def image-attrs
|
||||
"A set of attrs that corresponds to image data type"
|
||||
|
||||
@@ -119,12 +119,15 @@
|
||||
|
||||
(defn write-image-fill
|
||||
[offset buffer opacity image]
|
||||
(let [image-id (get image :id)
|
||||
image-width (get image :width)
|
||||
image-height (get image :height)
|
||||
alpha (mth/floor (* opacity 0xff))
|
||||
keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
|
||||
flags (bit-or keep-aspect-ratio 0x00)]
|
||||
(let [image-id (get image :id)
|
||||
image-width (get image :width)
|
||||
image-height (get image :height)
|
||||
alpha (mth/floor (* opacity 0xff))
|
||||
keep-aspect-ratio (if (get image :keep-aspect-ratio false) 0x01 0x00)
|
||||
transform (get image :transform)
|
||||
has-transform? (some? transform)
|
||||
transform-flag (if has-transform? 0x02 0x00)
|
||||
flags (bit-or keep-aspect-ratio transform-flag)]
|
||||
(buf/write-byte buffer (+ offset 0) 0x03)
|
||||
(buf/write-uuid buffer (+ offset 4) image-id)
|
||||
(buf/write-byte buffer (+ offset 20) alpha)
|
||||
@@ -132,6 +135,17 @@
|
||||
(buf/write-short buffer (+ offset 22) 0) ;; 2-byte padding (reserved for future use)
|
||||
(buf/write-int buffer (+ offset 24) image-width)
|
||||
(buf/write-int buffer (+ offset 28) image-height)
|
||||
(if has-transform?
|
||||
(do
|
||||
(buf/write-float buffer (+ offset 32) (double (get transform :x 0.0)))
|
||||
(buf/write-float buffer (+ offset 36) (double (get transform :y 0.0)))
|
||||
(buf/write-float buffer (+ offset 40) (double (get transform :width 1.0)))
|
||||
(buf/write-float buffer (+ offset 44) (double (get transform :height 1.0))))
|
||||
(do
|
||||
(buf/write-float buffer (+ offset 32) 0.0)
|
||||
(buf/write-float buffer (+ offset 36) 0.0)
|
||||
(buf/write-float buffer (+ offset 40) 1.0)
|
||||
(buf/write-float buffer (+ offset 44) 1.0)))
|
||||
(+ offset FILL-U8-SIZE)))
|
||||
|
||||
(defn- write-metadata
|
||||
@@ -208,28 +222,36 @@
|
||||
:type type}})
|
||||
|
||||
3 ;; image fill
|
||||
(let [id (buf/read-uuid dbuffer (+ doffset 4))
|
||||
alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
|
||||
opacity (mth/precision (/ alpha 0xff) 2)
|
||||
flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
|
||||
ratio (boolean (bit-and flags 0x01))
|
||||
width (buf/read-int dbuffer (+ doffset 24))
|
||||
height (buf/read-int dbuffer (+ doffset 28))
|
||||
mtype (buf/read-short mbuffer (+ moffset 2))
|
||||
mtype (case mtype
|
||||
0x01 "image/jpeg"
|
||||
0x02 "image/png"
|
||||
0x03 "image/gif"
|
||||
0x04 "image/webp"
|
||||
0x05 "image/svg+xml")]
|
||||
(let [id (buf/read-uuid dbuffer (+ doffset 4))
|
||||
alpha (buf/read-unsigned-byte dbuffer (+ doffset 20))
|
||||
opacity (mth/precision (/ alpha 0xff) 2)
|
||||
flags (buf/read-unsigned-byte dbuffer (+ doffset 21))
|
||||
ratio (not (zero? (bit-and flags 0x01)))
|
||||
has-tf (not (zero? (bit-and flags 0x02)))
|
||||
width (buf/read-int dbuffer (+ doffset 24))
|
||||
height (buf/read-int dbuffer (+ doffset 28))
|
||||
transform (when has-tf
|
||||
{:x (buf/read-float dbuffer (+ doffset 32))
|
||||
:y (buf/read-float dbuffer (+ doffset 36))
|
||||
:width (buf/read-float dbuffer (+ doffset 40))
|
||||
:height (buf/read-float dbuffer (+ doffset 44))})
|
||||
mtype (buf/read-short mbuffer (+ moffset 2))
|
||||
mtype (case mtype
|
||||
0x01 "image/jpeg"
|
||||
0x02 "image/png"
|
||||
0x03 "image/gif"
|
||||
0x04 "image/webp"
|
||||
0x05 "image/svg+xml")]
|
||||
{:fill-opacity opacity
|
||||
:fill-image {:id id
|
||||
:width width
|
||||
:height height
|
||||
:mtype mtype
|
||||
:keep-aspect-ratio ratio
|
||||
;; FIXME: we are not encodign the name, looks useless
|
||||
:name "sample"}}))]
|
||||
:fill-image (cond-> {:id id
|
||||
:width width
|
||||
:height height
|
||||
:mtype mtype
|
||||
:keep-aspect-ratio ratio
|
||||
;; FIXME: we are not encodign the name, looks useless
|
||||
:name "sample"}
|
||||
(some? transform)
|
||||
(assoc :transform transform))}))]
|
||||
|
||||
(if refs?
|
||||
(let [ref-file (buf/read-uuid mbuffer (+ moffset 4))
|
||||
|
||||
@@ -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))))))
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 450 KiB After Width: | Height: | Size: 471 KiB |
|
Before Width: | Height: | Size: 519 KiB After Width: | Height: | Size: 665 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 249 KiB |
@@ -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]
|
||||
|
||||
@@ -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 _]
|
||||
|
||||
@@ -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 %))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(¤t_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(¤t_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(¤t_path, segs_a, &other_path, segs_b),
|
||||
BoolType::Difference => difference(¤t_path, segs_a, &other_path, segs_b),
|
||||
BoolType::Intersection => intersection(¤t_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, ¤t_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(¤t_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(¤t_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(¤t_path, segs_a, &other_path, segs_b),
|
||||
BoolType::Difference => difference(¤t_path, segs_a, &other_path, segs_b),
|
||||
BoolType::Intersection => intersection(¤t_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,
|
||||
¤t_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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 ¶.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(¶),
|
||||
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(¶),
|
||||
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(¶).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||