From 89d959101187e3403727a902a70de9fe5c77a480 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 4 Dec 2025 16:18:01 +0100 Subject: [PATCH] :tada: Improve svg import --- .../src/app/common/files/shapes_builder.cljc | 161 +++++++++++++++++- frontend/src/app/render_wasm/api.cljs | 16 +- frontend/src/app/render_wasm/shape.cljs | 19 ++- frontend/src/app/render_wasm/svg_fills.cljs | 52 +++++- frontend/src/app/render_wasm/svg_filters.cljs | 98 +++++++++++ .../test/frontend_tests/svg_fills_test.cljs | 31 ++++ .../test/frontend_tests/svg_filters_test.cljs | 49 ++++++ 7 files changed, 393 insertions(+), 33 deletions(-) create mode 100644 frontend/src/app/render_wasm/svg_filters.cljs create mode 100644 frontend/test/frontend_tests/svg_filters_test.cljs diff --git a/common/src/app/common/files/shapes_builder.cljc b/common/src/app/common/files/shapes_builder.cljc index 2db8f13f0a..004b495583 100644 --- a/common/src/app/common/files/shapes_builder.cljc +++ b/common/src/app/common/files/shapes_builder.cljc @@ -82,6 +82,113 @@ (declare create-svg-children) (declare parse-svg-element) +(defn- process-gradient-stops + "Processes gradient stops to extract stop-color and stop-opacity from style attributes + and convert them to direct attributes. This ensures stops with style='stop-color:#...;stop-opacity:1' + are properly converted to stop-color and stop-opacity attributes." + [stops] + (mapv (fn [stop] + (let [stop-attrs (:attrs stop) + stop-style (get stop-attrs :style) + ;; Parse style if it's a string using csvg/parse-style utility + parsed-style (when (and (string? stop-style) (seq stop-style)) + (csvg/parse-style stop-style)) + ;; Extract stop-color and stop-opacity from style + style-stop-color (when parsed-style (:stop-color parsed-style)) + style-stop-opacity (when parsed-style (:stop-opacity parsed-style)) + ;; Merge: use direct attributes first, then style values as fallback + final-attrs (cond-> stop-attrs + (and style-stop-color (not (contains? stop-attrs :stop-color))) + (assoc :stop-color style-stop-color) + + (and style-stop-opacity (not (contains? stop-attrs :stop-opacity))) + (assoc :stop-opacity style-stop-opacity) + + ;; Remove style attribute if we've extracted its values + (or style-stop-color style-stop-opacity) + (dissoc :style))] + (assoc stop :attrs final-attrs))) + stops)) + +(defn- resolve-gradient-href + "Resolves xlink:href references in gradients by merging the referenced gradient's + stops and attributes with the referencing gradient. This ensures gradients that + reference other gradients (like linearGradient3550 referencing linearGradient3536) + inherit the stops from the base gradient. + + According to SVG spec, when a gradient has xlink:href: + - It inherits all attributes from the referenced gradient + - It inherits all stops from the referenced gradient + - The referencing gradient's attributes override the base ones + - If the referencing gradient has stops, they replace the base stops + + Returns the defs map with all gradient href references resolved." + [defs] + (letfn [(resolve-gradient [gradient-id gradient-node defs visited] + (if (contains? visited gradient-id) + (do + #?(:cljs (js/console.warn "[resolve-gradient] Circular reference detected for" gradient-id) + :clj nil) + gradient-node) ;; Avoid circular references + (let [attrs (:attrs gradient-node) + href-id (or (:href attrs) (:xlink:href attrs)) + href-id (when (and (string? href-id) (pos? (count href-id))) + (subs href-id 1)) ;; Remove leading # + + base-gradient (when (and href-id (contains? defs href-id)) + (get defs href-id)) + + resolved-base (when base-gradient (resolve-gradient href-id base-gradient defs (conj visited gradient-id)))] + + (if resolved-base + ;; Merge: base gradient attributes + referencing gradient attributes + ;; Use referencing gradient's stops if present, otherwise use base stops + (let [base-attrs (:attrs resolved-base) + ref-attrs (:attrs gradient-node) + + ;; Start with base attributes (without id), then merge with ref attributes + ;; This ensures ref attributes override base ones + base-attrs-clean (dissoc base-attrs :id) + ref-attrs-clean (dissoc ref-attrs :href :xlink:href :id) + + ;; Special handling for gradientTransform: if both have it, combine them + base-transform (get base-attrs :gradientTransform) + ref-transform (get ref-attrs :gradientTransform) + combined-transform (cond + (and base-transform ref-transform) + (str base-transform " " ref-transform) ;; Apply base first, then ref + :else (or ref-transform base-transform)) + + ;; Merge attributes: base first, then ref (ref overrides) + merged-attrs (-> (d/deep-merge base-attrs-clean ref-attrs-clean) + (cond-> combined-transform + (assoc :gradientTransform combined-transform))) + + ;; If referencing gradient has content (stops), use it; otherwise use base content + final-content (if (seq (:content gradient-node)) + (:content gradient-node) + (:content resolved-base)) + + ;; Process stops to extract stop-color and stop-opacity from style attributes + processed-content (process-gradient-stops final-content) + + result {:tag (:tag gradient-node) + :attrs (assoc merged-attrs :id gradient-id) + :content processed-content}] + result) + ;; Process stops even for gradients without references to extract style attributes + (let [processed-content (process-gradient-stops (:content gradient-node))] + (assoc gradient-node :content processed-content))))))] + (let [gradient-tags #{:linearGradient :radialGradient} + result (reduce-kv + (fn [acc id node] + (if (contains? gradient-tags (:tag node)) + (assoc acc id (resolve-gradient id node defs #{})) + (assoc acc id node))) + {} + defs)] + result))) + (defn create-svg-shapes ([svg-data pos objects frame-id parent-id selected center?] (create-svg-shapes (uuid/next) svg-data pos objects frame-id parent-id selected center?)) @@ -112,6 +219,9 @@ (csvg/fix-percents) (csvg/extract-defs)) + ;; Resolve gradient href references in all defs before processing shapes + def-nodes (resolve-gradient-href def-nodes) + ;; In penpot groups have the size of their children. To ;; respect the imported svg size and empty space let's create ;; a transparent shape as background to respect the imported @@ -142,12 +252,23 @@ (reduce (partial create-svg-children objects selected frame-id root-id svg-data) [unames []] (d/enumerate (->> (:content svg-data) - (mapv #(csvg/inherit-attributes root-attrs %)))))] + (mapv #(csvg/inherit-attributes root-attrs %))))) - [root-shape children]))) + ;; Collect all defs from children and merge into root shape + all-defs-from-children (reduce (fn [acc child] + (if-let [child-defs (:svg-defs child)] + (merge acc child-defs) + acc)) + {} + children) + + ;; Merge defs from svg-data and children into root shape + root-shape-with-defs (assoc root-shape :svg-defs (merge def-nodes all-defs-from-children))] + + [root-shape-with-defs children]))) (defn create-raw-svg - [name frame-id {:keys [x y width height offset-x offset-y]} {:keys [attrs] :as data}] + [name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs] :as data}] (let [props (csvg/attrs->props attrs) vbox (grc/make-rect offset-x offset-y width height)] (cts/setup-shape @@ -160,10 +281,11 @@ :y y :content data :svg-attrs props - :svg-viewbox vbox}))) + :svg-viewbox vbox + :svg-defs defs}))) (defn create-svg-root - [id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs]}] + [id frame-id parent-id {:keys [name x y width height offset-x offset-y attrs defs] :as svg-data}] (let [props (-> (dissoc attrs :viewBox :view-box :xmlns) (d/without-keys csvg/inheritable-props) (csvg/attrs->props))] @@ -177,7 +299,8 @@ :height height :x (+ x offset-x) :y (+ y offset-y) - :svg-attrs props}))) + :svg-attrs props + :svg-defs defs}))) (defn create-svg-children [objects selected frame-id parent-id svg-data [unames children] [_index svg-element]] @@ -198,7 +321,7 @@ (defn create-group - [name frame-id {:keys [x y width height offset-x offset-y] :as svg-data} {:keys [attrs]}] + [name frame-id {:keys [x y width height offset-x offset-y defs] :as svg-data} {:keys [attrs]}] (let [transform (csvg/parse-transform (:transform attrs)) attrs (-> attrs (d/without-keys csvg/inheritable-props) @@ -214,7 +337,8 @@ :height height :svg-transform transform :svg-attrs attrs - :svg-viewbox vbox}))) + :svg-viewbox vbox + :svg-defs defs}))) (defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}] (when (and (contains? attrs :d) (seq (:d attrs))) @@ -523,6 +647,21 @@ :else (dm/str tag))] (dm/str "svg-" suffix))) +(defn- filter-valid-def-references + "Filters out false positive references that are not valid def IDs. + Filters out: + - Colors in style attributes (hex colors like #f9dd67) + - Style fragments that contain CSS keywords (like stop-opacity) + - References that don't exist in defs" + [ref-ids defs] + (let [is-style-fragment? (fn [ref-id] + (or (clr/hex-color-string? (str "#" ref-id)) + (str/includes? ref-id ";") ;; Contains CSS separator + (str/includes? ref-id "stop-opacity") ;; CSS keyword + (str/includes? ref-id "stop-color")))] ;; CSS keyword + (->> ref-ids + (remove is-style-fragment?) ;; Filter style fragments and hex colors + (filter #(contains? defs %))))) ;; Only existing defs (defn parse-svg-element [frame-id svg-data {:keys [tag attrs hidden] :as element} unames] @@ -534,7 +673,11 @@ (let [name (or (:id attrs) (tag->name tag)) att-refs (csvg/find-attr-references attrs) defs (get svg-data :defs) - references (csvg/find-def-references defs att-refs) + valid-refs (filter-valid-def-references att-refs defs) + all-refs (csvg/find-def-references defs valid-refs) + ;; Filter the final result to ensure all references are valid defs + ;; This prevents false positives from style attributes in gradient stops + references (filter-valid-def-references all-refs defs) href-id (or (:href attrs) (:xlink:href attrs) " ") href-id (if (and (string? href-id) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index b98aaa98f2..96ec51eb50 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -33,7 +33,7 @@ [app.render-wasm.performance :as perf] [app.render-wasm.serializers :as sr] [app.render-wasm.serializers.color :as sr-clr] - [app.render-wasm.svg-fills :as svg-fills] + [app.render-wasm.svg-filters :as svg-filters] ;; FIXME: rename; confunsing name [app.render-wasm.wasm :as wasm] [app.util.debug :as dbg] @@ -909,7 +909,8 @@ (defn set-object [objects shape] (perf/begin-measure "set-object") - (let [id (dm/get-prop shape :id) + (let [shape (svg-filters/apply-svg-derived shape) + id (dm/get-prop shape :id) type (dm/get-prop shape :type) parent-id (get shape :parent-id) @@ -923,14 +924,7 @@ rotation (get shape :rotation) transform (get shape :transform) - ;; If the shape comes from an imported SVG (we know this because - ;; it has the :svg-attrs attribute) and it does not have its - ;; own fill, we set a default black fill. This fill will be - ;; inherited by child nodes and emulates the behavior of - ;; standard SVG, where a node without an explicit fill - ;; defaults to black. - fills (svg-fills/resolve-shape-fills shape) - + fills (get shape :fills) strokes (if (= type :group) [] (get shape :strokes)) children (get shape :shapes) @@ -974,7 +968,7 @@ (set-shape-svg-attrs svg-attrs)) (when (and (some? content) (= type :svg-raw)) (set-shape-svg-raw-content (get-static-markup shape))) - (when (some? shadows) (set-shape-shadows shadows)) + (set-shape-shadows shadows) (when (= type :text) (set-shape-grow-type grow-type)) diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 95513ed1a0..37e6895e0c 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -14,7 +14,7 @@ [app.common.types.shape.layout :as ctl] [app.main.refs :as refs] [app.render-wasm.api :as api] - [app.render-wasm.svg-fills :as svg-fills] + [app.render-wasm.svg-filters :as svg-filters] [app.render-wasm.wasm :as wasm] [beicon.v2.core :as rx] [cljs.core :as c] @@ -130,7 +130,11 @@ (defn- set-wasm-attr! [shape k] (when wasm/context-initialized? - (let [v (get shape k) + (let [shape (case k + :svg-attrs (svg-filters/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs))) + (:fills :blur :shadow) (svg-filters/apply-svg-derived shape) + shape) + v (get shape k) id (get shape :id)] (case k :parent-id @@ -163,8 +167,7 @@ (api/set-shape-transform v) :fills - (let [fills (svg-fills/resolve-shape-fills shape)] - (into [] (api/set-shape-fills id fills false))) + (api/set-shape-fills id v false) :strokes (into [] (api/set-shape-strokes id v false)) @@ -222,8 +225,12 @@ v]) :svg-attrs - (when (cfh/path-shape? shape) - (api/set-shape-svg-attrs v)) + (do + (api/set-shape-svg-attrs v) + ;; Always update fills/blur/shadow to clear previous state if filters disappear + (api/set-shape-fills id (:fills shape) false) + (api/set-shape-blur (:blur shape)) + (api/set-shape-shadows (:shadow shape))) :masked-group (when (cfh/mask-shape? shape) diff --git a/frontend/src/app/render_wasm/svg_fills.cljs b/frontend/src/app/render_wasm/svg_fills.cljs index ba9b40d3a9..b7e77afa54 100644 --- a/frontend/src/app/render_wasm/svg_fills.cljs +++ b/frontend/src/app/render_wasm/svg_fills.cljs @@ -74,6 +74,30 @@ :width (max 0.01 (or (dm/get-prop shape :width) 1)) :height (max 0.01 (or (dm/get-prop shape :height) 1))})))) +(defn- apply-svg-transform + "Applies SVG transform to a point if present." + [pt svg-transform] + (if svg-transform + (gpt/transform pt svg-transform) + pt)) + +(defn- apply-viewbox-transform + "Transforms a point from viewBox space to selrect space." + [pt viewbox rect] + (if viewbox + (let [{svg-x :x svg-y :y svg-width :width svg-height :height} viewbox + rect-width (max 0.01 (dm/get-prop rect :width)) + rect-height (max 0.01 (dm/get-prop rect :height)) + origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0) + origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0) + scale-x (/ rect-width svg-width) + scale-y (/ rect-height svg-height) + ;; Transform from viewBox space to selrect space + transformed-x (+ origin-x (* (- (dm/get-prop pt :x) svg-x) scale-x)) + transformed-y (+ origin-y (* (- (dm/get-prop pt :y) svg-y) scale-y))] + (gpt/point transformed-x transformed-y)) + pt)) + (defn- normalize-point [pt units shape] (if (= units "userspaceonuse") @@ -81,9 +105,16 @@ width (max 0.01 (dm/get-prop rect :width)) height (max 0.01 (dm/get-prop rect :height)) origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0) - origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)] - (gpt/point (/ (- (dm/get-prop pt :x) origin-x) width) - (/ (- (dm/get-prop pt :y) origin-y) height))) + origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0) + svg-transform (:svg-transform shape) + viewbox (:svg-viewbox shape) + ;; For userSpaceOnUse, coordinates are in SVG user space + ;; We need to transform them to shape space before normalizing + pt-after-svg-transform (apply-svg-transform pt svg-transform) + transformed-pt (apply-viewbox-transform pt-after-svg-transform viewbox rect) + normalized-x (/ (- (dm/get-prop transformed-pt :x) origin-x) width) + normalized-y (/ (- (dm/get-prop transformed-pt :y) origin-y) height)] + (gpt/point normalized-x normalized-y)) pt)) (defn- normalize-attrs @@ -257,18 +288,25 @@ (parse-gradient-stop node)))) vec)] (when (seq stops) - (let [[center radius-point] + (let [[center point-x point-y] (let [points (apply-gradient-transform [(gpt/point cx cy) - (gpt/point (+ cx r) cy)] + (gpt/point (+ cx r) cy) + (gpt/point cx (+ cy r))] transform)] (map #(normalize-point % units shape) points)) - radius (gpt/distance center radius-point)] + radius-x (gpt/distance center point-x) + radius-y (gpt/distance center point-y) + ;; Prefer Y as the base radius so width becomes the X/Y ratio. + base-radius (if (pos? radius-y) radius-y radius-x) + radius-point (if (pos? radius-y) point-y point-x) + width (let [safe-radius (max base-radius 1.0e-6)] + (/ radius-x safe-radius))] {:type :radial :start-x (dm/get-prop center :x) :start-y (dm/get-prop center :y) :end-x (dm/get-prop radius-point :x) :end-y (dm/get-prop radius-point :y) - :width radius + :width width :stops stops})))) (defn- svg-gradient->fill diff --git a/frontend/src/app/render_wasm/svg_filters.cljs b/frontend/src/app/render_wasm/svg_filters.cljs new file mode 100644 index 0000000000..699df81522 --- /dev/null +++ b/frontend/src/app/render_wasm/svg_filters.cljs @@ -0,0 +1,98 @@ +;; 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 + +(ns app.render-wasm.svg-filters + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.svg :as csvg] + [app.common.uuid :as uuid] + [app.render-wasm.svg-fills :as svg-fills])) + +(def ^:private drop-shadow-tags + #{:feOffset :feGaussianBlur :feColorMatrix}) + +(defn- find-filter-element + "Finds a filter element by tag in filter content." + [filter-content tag] + (some #(when (= tag (:tag %)) %) filter-content)) + +(defn- find-filter-def + [shape] + (let [filter-attr (or (dm/get-in shape [:svg-attrs :filter]) + (dm/get-in shape [:svg-attrs :style :filter])) + svg-defs (dm/get-prop shape :svg-defs)] + (when (and filter-attr svg-defs) + (let [filter-ids (csvg/extract-ids filter-attr)] + (some #(get svg-defs %) filter-ids))))) + +(defn- build-blur + [gaussian-blur] + (when gaussian-blur + {:id (uuid/next) + :type :layer-blur + ;; For layer blur the value matches stdDeviation directly + :value (-> (dm/get-in gaussian-blur [:attrs :stdDeviation]) + (d/parse-double 0)) + :hidden false})) + +(defn- build-drop-shadow + [filter-content drop-shadow-elements] + (let [offset-elem (find-filter-element filter-content :feOffset)] + (when (and offset-elem (seq drop-shadow-elements)) + (let [blur-elem (find-filter-element drop-shadow-elements :feGaussianBlur) + dx (-> (dm/get-in offset-elem [:attrs :dx]) + (d/parse-double 0)) + dy (-> (dm/get-in offset-elem [:attrs :dy]) + (d/parse-double 0)) + blur-value (if blur-elem + (-> (dm/get-in blur-elem [:attrs :stdDeviation]) + (d/parse-double 0) + (* 2)) + 0)] + [{:id (uuid/next) + :style :drop-shadow + :offset-x dx + :offset-y dy + :blur blur-value + :spread 0 + :hidden false + ;; TODO: parse feColorMatrix to extract color/opacity + :color {:color "#000000" :opacity 1}}])))) + +(defn apply-svg-filters + "Derives native blur/shadow from SVG filter definitions when the shape does + not already have them. The SVG attributes are left untouched so SVG fallback + rendering keeps working the same way as gradient fills." + [shape] + (let [existing-blur (:blur shape) + existing-shadow (:shadow shape)] + (if-let [filter-def (find-filter-def shape)] + (let [content (:content filter-def) + gaussian-blur (find-filter-element content :feGaussianBlur) + drop-shadow-elements (filter #(contains? drop-shadow-tags (:tag %)) content) + blur (or existing-blur (build-blur gaussian-blur)) + shadow (if (seq existing-shadow) + existing-shadow + (build-drop-shadow content drop-shadow-elements))] + (cond-> shape + blur (assoc :blur blur) + (seq shadow) (assoc :shadow shadow))) + shape))) + +(defn apply-svg-derived + "Applies SVG-derived effects (fills, blur, shadows) uniformly. + - Keeps user fills if present; otherwise derives from SVG. + - Converts SVG filters into native blur/shadow when needed. + - Always returns shape with :fills (possibly []) and blur/shadow keys." + [shape] + (let [shape' (apply-svg-filters shape) + fills (or (svg-fills/resolve-shape-fills shape') [])] + (assoc shape' + :fills fills + :blur (:blur shape') + :shadow (:shadow shape')))) + diff --git a/frontend/test/frontend_tests/svg_fills_test.cljs b/frontend/test/frontend_tests/svg_fills_test.cljs index a2f202c943..3f9d5788ed 100644 --- a/frontend/test/frontend_tests/svg_fills_test.cljs +++ b/frontend/test/frontend_tests/svg_fills_test.cljs @@ -42,6 +42,37 @@ (deftest skips-when-no-svg-fill (is (nil? (svg-fills/svg-fill->fills {:svg-attrs {:fill "none"}})))) +(def elliptical-shape + {:selrect {:x 0 :y 0 :width 200 :height 100} + :svg-attrs {:style {:fill "url(#grad-ellipse)"}} + :svg-defs {"grad-ellipse" + {:tag :radialGradient + :attrs {:id "grad-ellipse" + :gradientUnits "userSpaceOnUse" + :cx "50" + :cy "50" + :r "50" + :gradientTransform "matrix(2 0 0 1 0 0)"} + :content [{:tag :stop + :attrs {:offset "0" + :style "stop-color:#000000;stop-opacity:1"}} + {:tag :stop + :attrs {:offset "1" + :style "stop-color:#ffffff;stop-opacity:1"}}]}}}) + +(deftest builds-elliptical-radial-gradient-with-transform + (let [fills (svg-fills/svg-fill->fills elliptical-shape) + gradient (get-in (first fills) [:fill-color-gradient])] + (testing "ellipse from gradientTransform is preserved" + (is (= 1 (count fills))) + (is (= :radial (:type gradient))) + (is (= 0.5 (:start-x gradient))) + (is (= 0.5 (:start-y gradient))) + (is (= 0.5 (:end-x gradient))) + (is (= 1.0 (:end-y gradient))) + ;; Scaling the X axis in the gradientTransform should reflect on width. + (is (= 1.0 (:width gradient)))))) + (deftest resolve-shape-fills-prefers-existing-fills (let [fills [{:fill-color "#ff00ff" :fill-opacity 0.75}] resolved (svg-fills/resolve-shape-fills {:fills fills})] diff --git a/frontend/test/frontend_tests/svg_filters_test.cljs b/frontend/test/frontend_tests/svg_filters_test.cljs new file mode 100644 index 0000000000..d469183389 --- /dev/null +++ b/frontend/test/frontend_tests/svg_filters_test.cljs @@ -0,0 +1,49 @@ +;; 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 + +(ns frontend-tests.svg-filters-test + (:require + [app.render-wasm.svg-filters :as svg-filters] + [cljs.test :refer [deftest is testing]])) + +(def sample-filter-shape + {:svg-attrs {:filter "url(#simple-filter)"} + :svg-defs {"simple-filter" + {:tag :filter + :content [{:tag :feOffset :attrs {:dx "2" :dy "3"}} + {:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]}}}) + +(deftest derives-blur-and-shadow-from-svg-filter + (let [shape (svg-filters/apply-svg-filters sample-filter-shape) + blur (:blur shape) + shadow (:shadow shape)] + (testing "layer blur derived from feGaussianBlur" + (is (= :layer-blur (:type blur))) + (is (= 4.0 (:value blur)))) + (testing "drop shadow derived from filter chain" + (is (= [{:style :drop-shadow + :offset-x 2.0 + :offset-y 3.0 + :blur 8.0 + :spread 0 + :hidden false + :color {:color "#000000" :opacity 1}}] + (map #(dissoc % :id) shadow)))) + (testing "svg attrs remain intact" + (is (= "url(#simple-filter)" (get-in shape [:svg-attrs :filter])))))) + +(deftest keeps-existing-native-filters + (let [existing {:blur {:id :existing :type :layer-blur :value 1.0} + :shadow [{:id :shadow :style :drop-shadow}]} + shape (svg-filters/apply-svg-filters (merge sample-filter-shape existing))] + (is (= (:blur existing) (:blur shape))) + (is (= (:shadow existing) (:shadow shape))))) + +(deftest skips-when-no-filter-definition + (let [shape {:svg-attrs {:fill "#fff"}} + result (svg-filters/apply-svg-filters shape)] + (is (= shape result)))) +