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