mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
A text image fill was always laid over the whole text box, so a pasted image span showed another part of the picture in a box of another size. Text image fills now honour the image transform, as shape fills do: the image covers the transform rect instead of the whole box. Paste gives each pasted image span a transform that keeps the image at the same pixel offset from the span, like the gradients. The SVG export emits one linked image per span transform, rather than one per image id, and the style panel keeps the transform it reads back. AI-assisted-by: claude-opus-5-5
290 lines
14 KiB
Clojure
290 lines
14 KiB
Clojure
;; 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 frontend-tests.render-wasm.text-paste-test
|
|
"Restyling pasted text. Test content already holds the pasted text in the
|
|
style of the span it went into, as after `text-editor-sync-content`."
|
|
(:require
|
|
[app.render-wasm.text-paste :as text-paste]
|
|
[cljs.test :as t :include-macros true]))
|
|
|
|
(def ^:private regular
|
|
{:font-id "sourcesanspro" :font-variant-id "regular"
|
|
:font-weight "400" :font-style "normal"})
|
|
|
|
(defn- span
|
|
([text] (span text {}))
|
|
([text attrs] (merge regular attrs {:text text})))
|
|
|
|
(defn- content [& paragraphs]
|
|
{:type "root"
|
|
:children [{:type "paragraph-set"
|
|
:children (mapv (fn [spans] {:type "paragraph" :children spans})
|
|
paragraphs)}]})
|
|
|
|
(defn- spans-of [content]
|
|
(mapv :children (-> content :children first :children)))
|
|
|
|
(defn- fragment-paragraph [& runs]
|
|
{:attrs {} :children (vec runs)})
|
|
|
|
(defn- run
|
|
([text] (run text {}))
|
|
([text attrs] {:text text :attrs attrs}))
|
|
|
|
(def ^:private bold-variant
|
|
{:font-weight "700" :font-style "normal" :font-variant-id "bold"})
|
|
|
|
(t/deftest fragment->text
|
|
(t/testing "paragraphs are joined by newlines"
|
|
(t/is (= "ab\n\nc"
|
|
(text-paste/fragment->text
|
|
[(fragment-paragraph (run "a") (run "b"))
|
|
(fragment-paragraph)
|
|
(fragment-paragraph (run "c"))])))))
|
|
|
|
(t/deftest bold-run-resolves-to-a-font-variant
|
|
(t/testing "a bold run inside a paragraph gets the bold variant of the span's font"
|
|
(let [pasted (content [(span "Hello big world")])
|
|
fragment [(fragment-paragraph (run "big " {:font-weight "700"}))]
|
|
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 6})]
|
|
(t/is (= [[(span "Hello ") (span "big " bold-variant) (span "world")]]
|
|
(spans-of result))))))
|
|
|
|
(t/deftest italic-run-keeps-the-span-weight
|
|
(t/testing "italic over a bold span picks the bold italic variant"
|
|
(let [pasted (content [(span "x" bold-variant)])
|
|
fragment [(fragment-paragraph (run "x" {:font-style "italic"}))]
|
|
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})]
|
|
(t/is (= [[(span "x" {:font-weight "700" :font-style "italic"
|
|
:font-variant-id "bolditalic"})]]
|
|
(spans-of result))))))
|
|
|
|
(t/deftest html-emphasis-replaces-the-caret-emphasis
|
|
(let [no-emphasis {:font-weight "400" :font-style "normal"
|
|
:text-decoration "none" :text-transform "none"}]
|
|
(t/testing "plain source text pasted into bold, underlined text comes out plain"
|
|
(let [pasted (content [(span "aXb" (assoc bold-variant :text-decoration "underline"))])
|
|
fragment [(fragment-paragraph (run "X" no-emphasis))]
|
|
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 1})]
|
|
(t/is (= [[(span "a" (assoc bold-variant :text-decoration "underline"))
|
|
(span "X" {:text-decoration "none" :text-transform "none"})
|
|
(span "b" (assoc bold-variant :text-decoration "underline"))]]
|
|
(spans-of result)))))
|
|
|
|
(t/testing "not bold leaves a light weight as it is"
|
|
(let [light {:font-weight "300" :font-style "normal" :font-variant-id "300"}
|
|
pasted (content [(span "X" light)])
|
|
fragment [(fragment-paragraph (run "X" no-emphasis))]
|
|
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})]
|
|
(t/is (= [[(span "X" (assoc light :text-decoration "none" :text-transform "none"))]]
|
|
(spans-of result)))))))
|
|
|
|
(t/deftest unknown-font-keeps-its-weight
|
|
(t/testing "a font we have no data for cannot resolve a variant, so it stays"
|
|
(let [unknown (span "x" {:font-id "missing-font"})
|
|
fragment [(fragment-paragraph (run "x" {:font-weight "700"}))]
|
|
result (text-paste/apply-fragment-styles (content [unknown]) fragment {:para 0 :offset 0})]
|
|
(t/is (= [[unknown]] (spans-of result))))))
|
|
|
|
(t/deftest typography-link
|
|
(let [linked {:typography-ref-id "typo-id" :typography-ref-file "file-id"}]
|
|
(t/testing "a span changed by the paste leaves its typography"
|
|
(let [fragment [(fragment-paragraph (run "x" {:text-decoration "underline"}))]
|
|
result (text-paste/apply-fragment-styles
|
|
(content [(span "x" linked)]) fragment {:para 0 :offset 0})]
|
|
(t/is (= [[(span "x" {:text-decoration "underline"})]]
|
|
(spans-of result)))))
|
|
|
|
(t/testing "a span the paste leaves as it was keeps its typography"
|
|
(let [already (span "x" (merge bold-variant linked))
|
|
fragment [(fragment-paragraph (run "x" {:font-weight "700"}))]
|
|
result (text-paste/apply-fragment-styles (content [already]) fragment {:para 0 :offset 0})]
|
|
(t/is (= [[already]] (spans-of result)))))))
|
|
|
|
(t/deftest ranges-across-paragraphs
|
|
(t/testing "runs after the first paragraph start at offset 0 of their paragraph"
|
|
(let [pasted (content [(span "abX")] [(span "Ycd")])
|
|
fragment [(fragment-paragraph (run "X" {:font-weight "700"}))
|
|
(fragment-paragraph (run "Y" {:font-weight "700"}))]
|
|
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 2})]
|
|
(t/is (= [[(span "ab") (span "X" bold-variant)]
|
|
[(span "Y" bold-variant) (span "cd")]]
|
|
(spans-of result))))))
|
|
|
|
(t/deftest ranges-count-utf16-units
|
|
(t/testing "an emoji in the fragment moves later runs by its two UTF-16 units"
|
|
(let [pasted (content [(span "😀b")])
|
|
fragment [(fragment-paragraph (run "😀") (run "b" {:font-weight "700"}))]
|
|
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 0})]
|
|
(t/is (= [[(span "😀") (span "b" bold-variant)]]
|
|
(spans-of result)))))
|
|
|
|
(t/testing "a paste after an emoji starts at the UTF-16 offset WASM reports"
|
|
(let [pasted (content [(span "😀Xb")])
|
|
fragment [(fragment-paragraph (run "X" {:font-weight "700"}))]
|
|
result (text-paste/apply-fragment-styles pasted fragment {:para 0 :offset 2})]
|
|
(t/is (= [[(span "😀") (span "X" bold-variant) (span "b")]]
|
|
(spans-of result))))))
|
|
|
|
(t/deftest fragment->content
|
|
(t/testing "runs become spans over the base style, and empty paragraphs keep one empty span"
|
|
(let [result (text-paste/fragment->content
|
|
[(fragment-paragraph (run "Hello ") (run "bold" {:font-weight "700"}))
|
|
(fragment-paragraph)]
|
|
regular)]
|
|
(t/is (= [[(span "Hello ") (span "bold" bold-variant)]
|
|
[(span "")]]
|
|
(spans-of result)))))
|
|
|
|
(t/testing "paragraphs carry the base style"
|
|
(let [result (text-paste/fragment->content [(fragment-paragraph (run "x"))] regular)]
|
|
(t/is (= (assoc regular :type "paragraph")
|
|
(dissoc (-> result :children first :children first) :children))))))
|
|
|
|
;; --- Text copied in Penpot
|
|
|
|
(def ^:private brand
|
|
{:font-id "gfont-roboto" :font-family "Roboto" :font-variant-id "700italic"
|
|
:font-weight "700" :font-style "italic"
|
|
:fills [{:fill-color "#ff0000" :fill-opacity 1}]})
|
|
|
|
(t/deftest content->fragment
|
|
(t/testing "paragraphs and spans keep their whole style, and empty spans are dropped"
|
|
(t/is (= [{:attrs {:text-align "center"}
|
|
:children [{:text "x" :attrs brand}]}
|
|
{:attrs {:text-align "right"}
|
|
:children []}]
|
|
(text-paste/content->fragment
|
|
{:type "root"
|
|
:children [{:type "paragraph-set"
|
|
:children [{:type "paragraph" :text-align "center"
|
|
:children [(assoc brand :text "x") (assoc brand :text "")]}
|
|
{:type "paragraph" :text-align "right"
|
|
:children [(assoc brand :text "")]}]}]})))))
|
|
|
|
(t/deftest penpot-styles-replace-the-caret-style
|
|
(t/testing "pasted runs take their whole style, dropping the target's typography"
|
|
(let [pasted (content [(span "AxyB" {:typography-ref-id "t" :typography-ref-file "f"})])
|
|
fragment [(fragment-paragraph (run "x" brand) (run "y" regular))]
|
|
result (text-paste/apply-content-styles pasted fragment {:para 0 :offset 1})]
|
|
(t/is (= [[(span "A" {:typography-ref-id "t" :typography-ref-file "f"})
|
|
(assoc brand :text "x")
|
|
(span "y")
|
|
(span "B" {:typography-ref-id "t" :typography-ref-file "f"})]]
|
|
(spans-of result)))))
|
|
|
|
(t/testing "the first paragraph keeps its style; later ones bring theirs"
|
|
(let [pasted {:type "root"
|
|
:children [{:type "paragraph-set"
|
|
:children [{:type "paragraph" :text-align "left" :children [(span "Ax")]}
|
|
{:type "paragraph" :text-align "left" :children [(span "yB")]}]}]}
|
|
fragment [{:attrs {:text-align "center"} :children [(run "x" brand)]}
|
|
{:attrs {:text-align "right"} :children [(run "y" brand)]}]
|
|
result (text-paste/apply-content-styles pasted fragment {:para 0 :offset 1})]
|
|
(t/is (= ["left" "right"]
|
|
(map :text-align (-> result :children first :children))))
|
|
(t/is (= [[(span "A") (assoc brand :text "x")]
|
|
[(assoc brand :text "y") (span "B")]]
|
|
(spans-of result))))))
|
|
|
|
;; --- Gradients
|
|
|
|
(defn- gradient-fill [gradient]
|
|
{:fills [{:fill-color-gradient (merge {:type :linear :width 1 :stops []} gradient)}]})
|
|
|
|
(def ^:private source-box {:width 200 :height 40})
|
|
|
|
(def ^:private target-box {:width 100 :height 80})
|
|
|
|
(def ^:private source-geometry
|
|
{:origins [[{:x 50 :y 0 :box source-box}]]})
|
|
|
|
(def ^:private target-geometry
|
|
{:origins [[{:x 10 :y 40 :box target-box}]]})
|
|
|
|
(defn- pasted-gradient [content]
|
|
(-> (spans-of content) first second :fills first :fill-color-gradient))
|
|
|
|
(t/deftest gradients-keep-their-offset-from-the-span
|
|
(let [linear (gradient-fill {:start-x 0.25 :start-y 0 :end-x 0.75 :end-y 1})
|
|
pasted (content [(span "HELLO" linear)])
|
|
target (content [(span "abc") (span "HELLO" linear)])
|
|
result (text-paste/move-span-fills target pasted {:para 0 :offset 3}
|
|
source-geometry target-geometry)]
|
|
(t/testing "points move into the target box at the same pixel offsets"
|
|
(t/is (= {:start-x 0.1 :start-y 0.5 :end-x 1.1 :end-y 1}
|
|
(select-keys (pasted-gradient result) [:start-x :start-y :end-x :end-y]))))
|
|
(t/testing "a linear width stays as it was"
|
|
(t/is (= 1 (:width (pasted-gradient result)))))
|
|
(t/testing "text before the paste keeps its fills"
|
|
(t/is (= (span "abc") (-> (spans-of result) first first))))))
|
|
|
|
(t/deftest radial-width-follows-the-aspect-ratio
|
|
(let [radial (gradient-fill {:type :radial :start-x 0.5 :start-y 0.5 :end-x 0.5 :end-y 1})
|
|
pasted (content [(span "HELLO" radial)])
|
|
target (content [(span "abc") (span "HELLO" radial)])
|
|
result (text-paste/move-span-fills target pasted {:para 0 :offset 3}
|
|
source-geometry target-geometry)]
|
|
;; 200x40 is 5:1 and 100x80 is 5:4, so the width grows 4 times.
|
|
(t/is (= 4 (:width (pasted-gradient result))))))
|
|
|
|
(t/deftest spans-without-geometry-are-left-alone
|
|
(let [red {:fills [{:fill-color "#ff0000" :fill-opacity 1}]}
|
|
linear (gradient-fill {:start-x 0 :start-y 0 :end-x 1 :end-y 1})
|
|
pasted (content [(span "red" red) (span "grad" linear)])
|
|
target (content [(span "red" red) (span "grad" linear)])]
|
|
(t/testing "a span with no origin keeps its gradient"
|
|
(t/is (= target (text-paste/move-span-fills
|
|
target pasted {:para 0 :offset 0}
|
|
{:origins [[nil nil]]}
|
|
{:origins [[nil nil]]}))))
|
|
(t/testing "an empty box leaves the gradients as they were"
|
|
(t/is (= target (text-paste/move-span-fills
|
|
target pasted {:para 0 :offset 0}
|
|
{:origins [[nil {:x 0 :y 0 :box source-box}]]}
|
|
{:origins [[nil {:x 0 :y 0 :box {:width 0 :height 80}}]]}))))))
|
|
|
|
(t/deftest each-span-moves-from-its-own-box
|
|
;; Copied text shapes are joined into one paste, each span keeping its box.
|
|
(let [linear (gradient-fill {:start-x 0.5 :start-y 0 :end-x 0.5 :end-y 1})
|
|
pasted (content [(span "a" linear)] [(span "b" linear)])
|
|
source {:origins [[{:x 0 :y 0 :box {:width 100 :height 20}}]
|
|
[{:x 0 :y 0 :box {:width 200 :height 40}}]]}
|
|
target {:origins [[{:x 0 :y 0 :box {:width 400 :height 80}}]
|
|
[{:x 0 :y 40 :box {:width 400 :height 80}}]]}
|
|
result (text-paste/move-span-fills pasted pasted {:para 0 :offset 0} source target)
|
|
points #(select-keys (-> % :fills first :fill-color-gradient)
|
|
[:start-x :start-y :end-x :end-y])]
|
|
(t/is (= [{:start-x 0.125 :start-y 0 :end-x 0.125 :end-y 0.25}
|
|
{:start-x 0.25 :start-y 0.5 :end-x 0.25 :end-y 1}]
|
|
(map (comp points first) (spans-of result))))))
|
|
|
|
;; --- Images
|
|
|
|
(defn- image-fill [image]
|
|
{:fills [{:fill-image (merge {:id #uuid "00000000-0000-0000-0000-000000000042"
|
|
:width 400 :height 200 :mtype "image/png"}
|
|
image)}]})
|
|
|
|
(defn- pasted-image-transform [content]
|
|
(-> (spans-of content) first second :fills first :fill-image :transform))
|
|
|
|
(t/deftest images-keep-their-offset-from-the-span
|
|
(let [move (fn [image]
|
|
(let [fill (image-fill image)]
|
|
(pasted-image-transform
|
|
(text-paste/move-span-fills (content [(span "abc") (span "HELLO" fill)])
|
|
(content [(span "HELLO" fill)])
|
|
{:para 0 :offset 3}
|
|
source-geometry target-geometry))))]
|
|
(t/testing "an image over the whole source box gets a transform over the same pixels"
|
|
(t/is (= {:x -0.4 :y 0.5 :width 2 :height 0.5}
|
|
(move {}))))
|
|
(t/testing "an image with a transform keeps its pixel rect"
|
|
(t/is (= {:x 0.1 :y 0.5 :width 1 :height 0.5}
|
|
(move {:transform {:x 0.25 :y 0 :width 0.5 :height 1}}))))))
|