mirror of
https://github.com/penpot/penpot.git
synced 2025-12-12 06:24:17 +01:00
🎉 Improve svg import
This commit is contained in:
@@ -82,6 +82,113 @@
|
|||||||
(declare create-svg-children)
|
(declare create-svg-children)
|
||||||
(declare parse-svg-element)
|
(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
|
(defn create-svg-shapes
|
||||||
([svg-data pos objects frame-id parent-id selected center?]
|
([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?))
|
(create-svg-shapes (uuid/next) svg-data pos objects frame-id parent-id selected center?))
|
||||||
@@ -112,6 +219,9 @@
|
|||||||
(csvg/fix-percents)
|
(csvg/fix-percents)
|
||||||
(csvg/extract-defs))
|
(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
|
;; In penpot groups have the size of their children. To
|
||||||
;; respect the imported svg size and empty space let's create
|
;; respect the imported svg size and empty space let's create
|
||||||
;; a transparent shape as background to respect the imported
|
;; 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)
|
(reduce (partial create-svg-children objects selected frame-id root-id svg-data)
|
||||||
[unames []]
|
[unames []]
|
||||||
(d/enumerate (->> (:content svg-data)
|
(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
|
(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)
|
(let [props (csvg/attrs->props attrs)
|
||||||
vbox (grc/make-rect offset-x offset-y width height)]
|
vbox (grc/make-rect offset-x offset-y width height)]
|
||||||
(cts/setup-shape
|
(cts/setup-shape
|
||||||
@@ -160,10 +281,11 @@
|
|||||||
:y y
|
:y y
|
||||||
:content data
|
:content data
|
||||||
:svg-attrs props
|
:svg-attrs props
|
||||||
:svg-viewbox vbox})))
|
:svg-viewbox vbox
|
||||||
|
:svg-defs defs})))
|
||||||
|
|
||||||
(defn create-svg-root
|
(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)
|
(let [props (-> (dissoc attrs :viewBox :view-box :xmlns)
|
||||||
(d/without-keys csvg/inheritable-props)
|
(d/without-keys csvg/inheritable-props)
|
||||||
(csvg/attrs->props))]
|
(csvg/attrs->props))]
|
||||||
@@ -177,7 +299,8 @@
|
|||||||
:height height
|
:height height
|
||||||
:x (+ x offset-x)
|
:x (+ x offset-x)
|
||||||
:y (+ y offset-y)
|
:y (+ y offset-y)
|
||||||
:svg-attrs props})))
|
:svg-attrs props
|
||||||
|
:svg-defs defs})))
|
||||||
|
|
||||||
(defn create-svg-children
|
(defn create-svg-children
|
||||||
[objects selected frame-id parent-id svg-data [unames children] [_index svg-element]]
|
[objects selected frame-id parent-id svg-data [unames children] [_index svg-element]]
|
||||||
@@ -198,7 +321,7 @@
|
|||||||
|
|
||||||
|
|
||||||
(defn create-group
|
(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))
|
(let [transform (csvg/parse-transform (:transform attrs))
|
||||||
attrs (-> attrs
|
attrs (-> attrs
|
||||||
(d/without-keys csvg/inheritable-props)
|
(d/without-keys csvg/inheritable-props)
|
||||||
@@ -214,7 +337,8 @@
|
|||||||
:height height
|
:height height
|
||||||
:svg-transform transform
|
:svg-transform transform
|
||||||
:svg-attrs attrs
|
: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}]
|
(defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}]
|
||||||
(when (and (contains? attrs :d) (seq (:d attrs)))
|
(when (and (contains? attrs :d) (seq (:d attrs)))
|
||||||
@@ -523,6 +647,21 @@
|
|||||||
:else (dm/str tag))]
|
:else (dm/str tag))]
|
||||||
(dm/str "svg-" suffix)))
|
(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
|
(defn parse-svg-element
|
||||||
[frame-id svg-data {:keys [tag attrs hidden] :as element} unames]
|
[frame-id svg-data {:keys [tag attrs hidden] :as element} unames]
|
||||||
@@ -534,7 +673,11 @@
|
|||||||
(let [name (or (:id attrs) (tag->name tag))
|
(let [name (or (:id attrs) (tag->name tag))
|
||||||
att-refs (csvg/find-attr-references attrs)
|
att-refs (csvg/find-attr-references attrs)
|
||||||
defs (get svg-data :defs)
|
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 (or (:href attrs) (:xlink:href attrs) " ")
|
||||||
href-id (if (and (string? href-id)
|
href-id (if (and (string? href-id)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
[app.render-wasm.performance :as perf]
|
[app.render-wasm.performance :as perf]
|
||||||
[app.render-wasm.serializers :as sr]
|
[app.render-wasm.serializers :as sr]
|
||||||
[app.render-wasm.serializers.color :as sr-clr]
|
[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
|
;; FIXME: rename; confunsing name
|
||||||
[app.render-wasm.wasm :as wasm]
|
[app.render-wasm.wasm :as wasm]
|
||||||
[app.util.debug :as dbg]
|
[app.util.debug :as dbg]
|
||||||
@@ -909,7 +909,8 @@
|
|||||||
(defn set-object
|
(defn set-object
|
||||||
[objects shape]
|
[objects shape]
|
||||||
(perf/begin-measure "set-object")
|
(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)
|
type (dm/get-prop shape :type)
|
||||||
|
|
||||||
parent-id (get shape :parent-id)
|
parent-id (get shape :parent-id)
|
||||||
@@ -923,14 +924,7 @@
|
|||||||
rotation (get shape :rotation)
|
rotation (get shape :rotation)
|
||||||
transform (get shape :transform)
|
transform (get shape :transform)
|
||||||
|
|
||||||
;; If the shape comes from an imported SVG (we know this because
|
fills (get shape :fills)
|
||||||
;; 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)
|
|
||||||
|
|
||||||
strokes (if (= type :group)
|
strokes (if (= type :group)
|
||||||
[] (get shape :strokes))
|
[] (get shape :strokes))
|
||||||
children (get shape :shapes)
|
children (get shape :shapes)
|
||||||
@@ -974,7 +968,7 @@
|
|||||||
(set-shape-svg-attrs svg-attrs))
|
(set-shape-svg-attrs svg-attrs))
|
||||||
(when (and (some? content) (= type :svg-raw))
|
(when (and (some? content) (= type :svg-raw))
|
||||||
(set-shape-svg-raw-content (get-static-markup shape)))
|
(set-shape-svg-raw-content (get-static-markup shape)))
|
||||||
(when (some? shadows) (set-shape-shadows shadows))
|
(set-shape-shadows shadows)
|
||||||
(when (= type :text)
|
(when (= type :text)
|
||||||
(set-shape-grow-type grow-type))
|
(set-shape-grow-type grow-type))
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
[app.common.types.shape.layout :as ctl]
|
[app.common.types.shape.layout :as ctl]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
[app.render-wasm.api :as api]
|
[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]
|
[app.render-wasm.wasm :as wasm]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[cljs.core :as c]
|
[cljs.core :as c]
|
||||||
@@ -130,7 +130,11 @@
|
|||||||
(defn- set-wasm-attr!
|
(defn- set-wasm-attr!
|
||||||
[shape k]
|
[shape k]
|
||||||
(when wasm/context-initialized?
|
(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)]
|
id (get shape :id)]
|
||||||
(case k
|
(case k
|
||||||
:parent-id
|
:parent-id
|
||||||
@@ -163,8 +167,7 @@
|
|||||||
(api/set-shape-transform v)
|
(api/set-shape-transform v)
|
||||||
|
|
||||||
:fills
|
:fills
|
||||||
(let [fills (svg-fills/resolve-shape-fills shape)]
|
(api/set-shape-fills id v false)
|
||||||
(into [] (api/set-shape-fills id fills false)))
|
|
||||||
|
|
||||||
:strokes
|
:strokes
|
||||||
(into [] (api/set-shape-strokes id v false))
|
(into [] (api/set-shape-strokes id v false))
|
||||||
@@ -222,8 +225,12 @@
|
|||||||
v])
|
v])
|
||||||
|
|
||||||
:svg-attrs
|
:svg-attrs
|
||||||
(when (cfh/path-shape? shape)
|
(do
|
||||||
(api/set-shape-svg-attrs v))
|
(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
|
:masked-group
|
||||||
(when (cfh/mask-shape? shape)
|
(when (cfh/mask-shape? shape)
|
||||||
|
|||||||
@@ -74,6 +74,30 @@
|
|||||||
:width (max 0.01 (or (dm/get-prop shape :width) 1))
|
:width (max 0.01 (or (dm/get-prop shape :width) 1))
|
||||||
:height (max 0.01 (or (dm/get-prop shape :height) 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
|
(defn- normalize-point
|
||||||
[pt units shape]
|
[pt units shape]
|
||||||
(if (= units "userspaceonuse")
|
(if (= units "userspaceonuse")
|
||||||
@@ -81,9 +105,16 @@
|
|||||||
width (max 0.01 (dm/get-prop rect :width))
|
width (max 0.01 (dm/get-prop rect :width))
|
||||||
height (max 0.01 (dm/get-prop rect :height))
|
height (max 0.01 (dm/get-prop rect :height))
|
||||||
origin-x (or (dm/get-prop rect :x) (dm/get-prop rect :x1) 0)
|
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)]
|
origin-y (or (dm/get-prop rect :y) (dm/get-prop rect :y1) 0)
|
||||||
(gpt/point (/ (- (dm/get-prop pt :x) origin-x) width)
|
svg-transform (:svg-transform shape)
|
||||||
(/ (- (dm/get-prop pt :y) origin-y) height)))
|
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))
|
pt))
|
||||||
|
|
||||||
(defn- normalize-attrs
|
(defn- normalize-attrs
|
||||||
@@ -257,18 +288,25 @@
|
|||||||
(parse-gradient-stop node))))
|
(parse-gradient-stop node))))
|
||||||
vec)]
|
vec)]
|
||||||
(when (seq stops)
|
(when (seq stops)
|
||||||
(let [[center radius-point]
|
(let [[center point-x point-y]
|
||||||
(let [points (apply-gradient-transform [(gpt/point cx cy)
|
(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)]
|
transform)]
|
||||||
(map #(normalize-point % units shape) points))
|
(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
|
{:type :radial
|
||||||
:start-x (dm/get-prop center :x)
|
:start-x (dm/get-prop center :x)
|
||||||
:start-y (dm/get-prop center :y)
|
:start-y (dm/get-prop center :y)
|
||||||
:end-x (dm/get-prop radius-point :x)
|
:end-x (dm/get-prop radius-point :x)
|
||||||
:end-y (dm/get-prop radius-point :y)
|
:end-y (dm/get-prop radius-point :y)
|
||||||
:width radius
|
:width width
|
||||||
:stops stops}))))
|
:stops stops}))))
|
||||||
|
|
||||||
(defn- svg-gradient->fill
|
(defn- svg-gradient->fill
|
||||||
|
|||||||
98
frontend/src/app/render_wasm/svg_filters.cljs
Normal file
98
frontend/src/app/render_wasm/svg_filters.cljs
Normal file
@@ -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'))))
|
||||||
|
|
||||||
@@ -42,6 +42,37 @@
|
|||||||
(deftest skips-when-no-svg-fill
|
(deftest skips-when-no-svg-fill
|
||||||
(is (nil? (svg-fills/svg-fill->fills {:svg-attrs {:fill "none"}}))))
|
(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
|
(deftest resolve-shape-fills-prefers-existing-fills
|
||||||
(let [fills [{:fill-color "#ff00ff" :fill-opacity 0.75}]
|
(let [fills [{:fill-color "#ff00ff" :fill-opacity 0.75}]
|
||||||
resolved (svg-fills/resolve-shape-fills {:fills fills})]
|
resolved (svg-fills/resolve-shape-fills {:fills fills})]
|
||||||
|
|||||||
49
frontend/test/frontend_tests/svg_filters_test.cljs
Normal file
49
frontend/test/frontend_tests/svg_filters_test.cljs
Normal file
@@ -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))))
|
||||||
|
|
||||||
Reference in New Issue
Block a user