From 214c64c49eadcf2ca04a1406c8fa54ca490bb2d8 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Tue, 2 Nov 2021 19:20:17 +0100 Subject: [PATCH] :bug: Fix problem when exporting texts with gradients or opacity --- CHANGES.md | 1 + exporter/src/app/renderer/svg.cljs | 125 +++++++++++++----- frontend/src/app/main/ui/context.cljs | 4 + frontend/src/app/main/ui/render.cljs | 18 +-- .../src/app/main/ui/shapes/gradients.cljs | 46 ++++--- frontend/src/app/main/ui/shapes/text.cljs | 122 ++++++++++++++++- frontend/src/app/util/color.cljs | 17 +++ 7 files changed, 271 insertions(+), 62 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 10a25a3f1..63118b728 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,7 @@ - Fix problem with exporting before the document is saved [Taiga #2189](https://tree.taiga.io/project/penpot/issue/2189) - Fix undo stacking when changing color from color-picker [Taiga #2191](https://tree.taiga.io/project/penpot/issue/2191) - Fix pages dropdown in viewer [Taiga #2087](https://tree.taiga.io/project/penpot/issue/2087) +- Fix problem when exporting texts with gradients or opacity [Taiga #2200](https://tree.taiga.io/project/penpot/issue/2200) ### :arrow_up: Deps updates ### :heart: Community contributions by (Thank you!) diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index 54544aa24..ca789f2e0 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -23,7 +23,7 @@ [app.renderer.bitmap :refer [create-cookie]] [promesa.core :as p])) -(log/set-level "app.http.export-svg" :trace) +(log/set-level "app.renderer.svg" :trace) (defn- xml->clj [data] @@ -129,7 +129,7 @@ svgpath (path/join basepath (str basename ".svg"))] (-> (sh/run-cmd! (str "potrace --flat -b svg " pbmpath " -o " svgpath)) (p/then (constantly svgpath))))) - + (generate-color-layer [ppmpath color] (log/trace :fn :generate-color-layer :ppmpath ppmpath :color color) (let [basepath (path/dirname ppmpath) @@ -146,15 +146,61 @@ {:color color :svgdata data})))))) - (join-color-layers [{:keys [x y width height] :as node} layers] - (log/trace :fn :join-color-layers) + (set-path-color [id color mapping node] + (let [color-mapping (get mapping color)] + (cond + (and (some? color-mapping) + (= "transparent" (get color-mapping "type"))) + (update node "attributes" assoc + "fill" (get color-mapping "hex") + "fill-opacity" (get color-mapping "opacity")) + + (and (some? color-mapping) + (= "gradient" (get color-mapping "type"))) + (update node "attributes" assoc + "fill" (str "url(#gradient-" id "-" (subs color 1) ")")) + + :else + (update node "attributes" assoc "fill" color)))) + + (get-stops [data] + (->> (get-in data ["gradient" "stops"]) + (mapv (fn [stop-data] + {"type" "element" + "name" "stop" + "attributes" {"offset" (get stop-data "offset") + "stop-color" (get stop-data "color") + "stop-opacity" (get stop-data "opacity")}})))) + + (data->gradient-def [id [color data]] + (let [id (str "gradient-" id "-" (subs color 1))] + (if (= type "linear") + {"type" "element" + "name" "linearGradient" + "attributes" {"id" id "x1" "0.5" "y1" "1" "x2" "0.5" "y2" "0"} + "elements" (get-stops data)} + + {"type" "element" + "name" "radialGradient" + "attributes" {"id" id "cx" "0.5" "cy" "0.5" "r" "0.5"} + "elements" (get-stops data)} + ))) + + (get-gradients [id mapping] + (->> mapping + (filter (fn [[color data]] + (= (get data "type") "gradient"))) + (mapv (partial data->gradient-def id)))) + + (join-color-layers [{:keys [id x y width height mapping] :as node} layers] + (log/trace :fn :join-color-layers :mapping mapping) (loop [result (-> (:svgdata (first layers)) (assoc "elements" [])) layers (seq layers)] (if-let [{:keys [color svgdata]} (first layers)] (recur (->> (get svgdata "elements") (filter #(= (get % "name") "g")) - (map #(update % "attributes" assoc "fill" color)) + (map (partial set-path-color id color mapping)) (update result "elements" d/concat)) (rest layers)) @@ -166,22 +212,33 @@ (parse-viewbox)) transform (str/fmt "translate(%s, %s) scale(%s, %s)" x y (/ width (:width vbox)) - (/ height (:height vbox)))] + (/ height (:height vbox))) + + gradient-defs (get-gradients id mapping) + + elements + (->> (get result "elements") + (mapv (fn [group] + (let [paths (get group "elements")] + (if (= 1 (count paths)) + (let [path (first paths)] + (update path "attributes" + (fn [attrs] + (-> attrs + (d/merge (get group "attributes")) + (update "transform" #(str transform " " %)))))) + (update-in group ["attributes" "transform"] #(str transform " " %))))))) + + + elements (cond->> elements + (not (empty? gradient-defs)) + (d/concat [{"type" "element" "name" "defs" "attributes" {} + "elements" gradient-defs}]))] + (-> result (assoc "name" "g") (assoc "attributes" {}) - (update "elements" (fn [elements] - (mapv (fn [group] - (let [paths (get group "elements")] - (if (= 1 (count paths)) - (let [path (first paths)] - (update path "attributes" - (fn [attrs] - (-> attrs - (d/merge (get group "attributes")) - (update "transform" #(str transform " " %)))))) - (update-in group ["attributes" "transform"] #(str transform " " %))))) - elements)))))))) + (assoc "elements" elements)))))) (convert-to-svg [ppmpath {:keys [colors] :as node}] (log/trace :fn :convert-to-svg :ppmpath ppmpath :colors colors) @@ -201,25 +258,28 @@ :svgdata svgdata)))) (extract-element-attrs [^js element] - (let [^js attrs (.. element -attributes) - ^js colors (.. element -dataset -colors)] - #js {:id (.. attrs -id -value) - :x (.. attrs -x -value) - :y (.. attrs -y -value) - :width (.. attrs -width -value) - :height (.. attrs -height -value) - :colors (.split colors ",")})) + (let [^js attrs (.. element -attributes) + ^js colors (.. element -dataset -colors) + ^js mapping (.. element -dataset -mapping)] + #js {:id (.. attrs -id -value) + :x (.. attrs -x -value) + :y (.. attrs -y -value) + :width (.. attrs -width -value) + :height (.. attrs -height -value) + :colors (.split colors ",") + :mapping (js/JSON.parse mapping)})) (extract-single-node [[shot node]] (log/trace :fn :extract-single-node) (p/let [attrs (bw/eval! node extract-element-attrs)] - {:id (unchecked-get attrs "id") - :x (unchecked-get attrs "x") - :y (unchecked-get attrs "y") - :width (unchecked-get attrs "width") - :height (unchecked-get attrs "height") - :colors (vec (unchecked-get attrs "colors")) + {:id (unchecked-get attrs "id") + :x (unchecked-get attrs "x") + :y (unchecked-get attrs "y") + :width (unchecked-get attrs "width") + :height (unchecked-get attrs "height") + :colors (vec (unchecked-get attrs "colors")) + :mapping (js->clj (unchecked-get attrs "mapping")) :data shot})) (resolve-text-node [page node] @@ -313,3 +373,4 @@ ".svg")) :length (alength content) :mime-type "image/svg+xml"})) + diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs index 631b4b6f5..e91e4e82a 100644 --- a/frontend/src/app/main/ui/context.cljs +++ b/frontend/src/app/main/ui/context.cljs @@ -11,6 +11,10 @@ (def render-ctx (mf/create-context nil)) (def def-ctx (mf/create-context false)) +;; This content is used to replace complex colors to simple ones +;; for text shapes in the export process +(def text-plain-colors-ctx (mf/create-context false)) + (def current-route (mf/create-context nil)) (def current-team-id (mf/create-context nil)) (def current-project-id (mf/create-context nil)) diff --git a/frontend/src/app/main/ui/render.cljs b/frontend/src/app/main/ui/render.cljs index 0263a4826..f7107e718 100644 --- a/frontend/src/app/main/ui/render.cljs +++ b/frontend/src/app/main/ui/render.cljs @@ -16,6 +16,7 @@ [app.main.exports :as exports] [app.main.repo :as repo] [app.main.store :as st] + [app.main.ui.context :as muc] [app.main.ui.shapes.embed :as embed] [app.main.ui.shapes.export :as ed] [app.main.ui.shapes.filters :as filters] @@ -124,14 +125,15 @@ ;; Auxiliary SVG for rendering text-shapes (when render-texts? (for [object text-shapes] - [:svg {:id (str "screenshot-text-" (:id object)) - :view-box (str "0 0 " (:width object) " " (:height object)) - :width (:width object) - :height (:height object) - :version "1.1" - :xmlns "http://www.w3.org/2000/svg" - :xmlnsXlink "http://www.w3.org/1999/xlink"} - [:& shape-wrapper {:shape (-> object (assoc :x 0 :y 0))}]]))])) + [:& (mf/provider muc/text-plain-colors-ctx) {:value true} + [:svg {:id (str "screenshot-text-" (:id object)) + :view-box (str "0 0 " (:width object) " " (:height object)) + :width (:width object) + :height (:height object) + :version "1.1" + :xmlns "http://www.w3.org/2000/svg" + :xmlnsXlink "http://www.w3.org/1999/xlink"} + [:& shape-wrapper {:shape (-> object (assoc :x 0 :y 0))}]]]))])) (defn- adapt-root-frame [objects object-id] diff --git a/frontend/src/app/main/ui/shapes/gradients.cljs b/frontend/src/app/main/ui/shapes/gradients.cljs index 51a34a84f..6ca3abbea 100644 --- a/frontend/src/app/main/ui/shapes/gradients.cljs +++ b/frontend/src/app/main/ui/shapes/gradients.cljs @@ -10,24 +10,10 @@ [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.main.ui.context :as muc] + [app.main.ui.shapes.export :as ed] [app.util.object :as obj] [rumext.alpha :as mf])) -(mf/defc linear-gradient [{:keys [id gradient shape]}] - (let [transform (when (= :path (:type shape)) (gsh/transform-matrix shape nil (gpt/point 0.5 0.5)))] - [:> :linearGradient #js {:id id - :x1 (:start-x gradient) - :y1 (:start-y gradient) - :x2 (:end-x gradient) - :y2 (:end-y gradient) - :gradientTransform transform - :penpot:gradient "true"} - (for [{:keys [offset color opacity]} (:stops gradient)] - [:stop {:key (str id "-stop-" offset) - :offset (or offset 0) - :stop-color color - :stop-opacity opacity}])])) - (defn add-metadata [props gradient] (-> props (obj/set! "penpot:gradient" "true") @@ -38,6 +24,30 @@ (obj/set! "penpot:end-y" (:end-y gradient)) (obj/set! "penpot:width" (:width gradient)))) +(mf/defc linear-gradient [{:keys [id gradient shape]}] + (let [transform (when (= :path (:type shape)) (gsh/transform-matrix shape nil (gpt/point 0.5 0.5))) + base-props #js {:id id + :x1 (:start-x gradient) + :y1 (:start-y gradient) + :x2 (:end-x gradient) + :y2 (:end-y gradient) + :gradientTransform transform} + + include-metadata? (mf/use-ctx ed/include-metadata-ctx) + + props (cond-> base-props + include-metadata? + (add-metadata gradient))] + + [:> :linearGradient props + (for [{:keys [offset color opacity]} (:stops gradient)] + [:stop {:key (str id "-stop-" offset) + :offset (or offset 0) + :stop-color color + :stop-opacity opacity}])])) + + + (mf/defc radial-gradient [{:keys [id gradient shape]}] (let [{:keys [x y width height]} (:selrect shape) transform (if (= :path (:type shape)) @@ -73,7 +83,11 @@ :gradientUnits "userSpaceOnUse" :gradientTransform transform} - props (-> base-props (add-metadata gradient))] + include-metadata? (mf/use-ctx ed/include-metadata-ctx) + + props (cond-> base-props + include-metadata? + (add-metadata gradient))] [:> :radialGradient props (for [{:keys [offset color opacity]} (:stops gradient)] [:stop {:key (str id "-stop-" offset) diff --git a/frontend/src/app/main/ui/shapes/text.cljs b/frontend/src/app/main/ui/shapes/text.cljs index b6e037dd0..88e48e13b 100644 --- a/frontend/src/app/main/ui/shapes/text.cljs +++ b/frontend/src/app/main/ui/shapes/text.cljs @@ -8,9 +8,12 @@ (:require [app.common.data :as d] [app.common.geom.shapes :as geom] + [app.main.ui.context :as muc] [app.main.ui.shapes.attrs :as attrs] [app.main.ui.shapes.text.styles :as sts] + [app.util.color :as uc] [app.util.object :as obj] + [cuerdas.core :as str] [rumext.alpha :as mf])) (mf/defc render-text @@ -73,12 +76,111 @@ (obj/set! "key" index))] [:> render-node props]))]))))) +(defn- next-color + "Given a set of colors try to get a color not yet used" + [colors] + (assert (set? colors)) + (loop [current-rgb [0 0 0]] + (let [current-hex (uc/rgb->hex current-rgb)] + (if (contains? colors current-hex) + (recur (uc/next-rgb current-rgb)) + current-hex)))) + +(defn- remap-colors + "Returns a new content replacing the original colors by their mapped 'simple color'" + [content color-mapping] + + (cond-> content + (and (:fill-opacity content) (< (:fill-opacity content) 1.0)) + (-> (assoc :fill-color (get color-mapping [(:fill-color content) (:fill-opacity content)])) + (assoc :fill-opacity 1.0)) + + (some? (:fill-color-gradient content)) + (-> (assoc :fill-color (get color-mapping (:fill-color-gradient content))) + (assoc :fill-opacity 1.0) + (dissoc :fill-color-gradient)) + + (contains? content :children) + (update :children #(mapv (fn [node] (remap-colors node color-mapping)) %)))) + +(defn- fill->color + "Given a content node returns the information about that node fill color" + [{:keys [fill-color fill-opacity fill-color-gradient]}] + + (cond + (some? fill-color-gradient) + {:type :gradient + :gradient fill-color-gradient} + + (and (string? fill-color) (some? fill-opacity) (not= fill-opacity 1)) + {:type :transparent + :hex fill-color + :opacity fill-opacity} + + (string? fill-color) + {:type :solid + :hex fill-color + :map-to fill-color})) + (defn- retrieve-colors + "Given a text shape returns a triple with the values: + - colors used as fills + - a mapping from simple solid colors to complex ones (transparents/gradients) + - the inverse of the previous mapping (to restore the value in the SVG)" [shape] - (let [colors (->> (:content shape) - (tree-seq map? :children) - (into #{"#000000"} (comp (map :fill-color) (filter string?))))] - (apply str (interpose "," colors)))) + (let [color-data + (->> (:content shape) + (tree-seq map? :children) + (map fill->color) + (filter some?)) + + colors (->> color-data + (into #{"#000000"} + (comp (filter #(= :solid (:type %))) + (map :hex)))) + + [colors color-data] + (loop [colors colors + head (first color-data) + tail (rest color-data) + result []] + + (if (nil? head) + [colors result] + + (if (= :solid (:type head)) + (recur colors + (first tail) + (rest tail) + (conj result head)) + + (let [next-color (next-color colors) + head (assoc head :map-to next-color) + colors (conj colors next-color)] + (recur colors + (first tail) + (rest tail) + (conj result head)))))) + + color-mapping-inverse + (->> color-data + (remove #(= :solid (:type %))) + (group-by :map-to) + (d/mapm #(first %2))) + + color-mapping + (merge + (->> color-data + (filter #(= :transparent (:type %))) + (map #(vector [(:hex %) (:opacity %)] (:map-to %))) + (into {})) + + (->> color-data + (filter #(= :gradient (:type %))) + (map #(vector (:gradient %) (:map-to %))) + (into {})))] + + [colors color-mapping color-mapping-inverse])) (mf/defc text-shape {::mf/wrap-props false @@ -88,11 +190,19 @@ grow-type (obj/get props "grow-type") ;; This is only needed in workspace ;; We add 8px to add a padding for the exporter ;; width (+ width 8) - ] + [colors color-mapping color-mapping-inverse] (retrieve-colors shape) + + plain-colors? (mf/use-ctx muc/text-plain-colors-ctx) + + content (cond-> content + plain-colors? + (remap-colors color-mapping))] + [:foreignObject {:x x :y y :id id - :data-colors (retrieve-colors shape) + :data-colors (->> colors (str/join ",")) + :data-mapping (-> color-mapping-inverse (clj->js) (js/JSON.stringify)) :transform (geom/transform-matrix shape) :width (if (#{:auto-width} grow-type) 100000 width) :height (if (#{:auto-height :auto-width} grow-type) 100000 height) diff --git a/frontend/src/app/util/color.cljs b/frontend/src/app/util/color.cljs index 3c540dc3e..407b339b8 100644 --- a/frontend/src/app/util/color.cljs +++ b/frontend/src/app/util/color.cljs @@ -7,6 +7,7 @@ (ns app.util.color "Color conversion utils." (:require + [app.common.exceptions :as ex] [app.util.object :as obj] [cuerdas.core :as str] [goog.color :as gcolor])) @@ -155,3 +156,19 @@ (def empty-color (into {} (map #(vector % nil)) [:color :id :file-id :gradient :opacity])) + +(defn next-rgb + "Given a color in rgb returns the next color" + [[r g b]] + (cond + (and (= 255 r) (= 255 g) (= 255 b)) + (ex/raise "Cannot get next color") + + (and (= 255 g) (= 255 b)) + [(inc r) 0 0] + + (= 255 b) + [r (inc g) 0] + + :else + [r g (inc b)]))