mirror of
https://github.com/penpot/penpot.git
synced 2026-09-30 15:56:17 +00:00
🐛 Fix SVG filter shadows and inherited group fills in render-wasm: (#11925)
* 🐛 Fix shadows derived from imported SVG filters * 🐛 Fix inherited group fills in exports and group drop shadows * 🔧 Run exporter tests on render-wasm changes
This commit is contained in:
parent
9d08e26cb3
commit
c44484a1e5
2
.github/workflows/tests-exporter.yml
vendored
2
.github/workflows/tests-exporter.yml
vendored
@ -9,6 +9,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'exporter/**'
|
- 'exporter/**'
|
||||||
- 'common/**'
|
- 'common/**'
|
||||||
|
- 'render-wasm/**'
|
||||||
|
|
||||||
types:
|
types:
|
||||||
- opened
|
- opened
|
||||||
@ -23,6 +24,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'exporter/**'
|
- 'exporter/**'
|
||||||
- 'common/**'
|
- 'common/**'
|
||||||
|
- 'render-wasm/**'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.geom.rect :as grc]
|
[app.common.geom.rect :as grc]
|
||||||
|
[app.common.math :as mth]
|
||||||
[app.common.svg :as csvg]
|
[app.common.svg :as csvg]
|
||||||
[app.common.types.color :as clr]
|
[app.common.types.color :as clr]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
@ -382,9 +383,6 @@
|
|||||||
[{:fill-color "#000000" :fill-opacity 1}]
|
[{:fill-color "#000000" :fill-opacity 1}]
|
||||||
:else [])))
|
:else [])))
|
||||||
|
|
||||||
(def ^:private drop-shadow-tags
|
|
||||||
#{:feOffset :feGaussianBlur :feColorMatrix})
|
|
||||||
|
|
||||||
(defn- find-filter-element
|
(defn- find-filter-element
|
||||||
"Finds a filter element by tag in filter content."
|
"Finds a filter element by tag in filter content."
|
||||||
[filter-content tag]
|
[filter-content tag]
|
||||||
@ -409,29 +407,80 @@
|
|||||||
(d/parse-double 0))
|
(d/parse-double 0))
|
||||||
:hidden false}))
|
:hidden false}))
|
||||||
|
|
||||||
|
(defn- filter-attr
|
||||||
|
"Attr of a filter primitive, whatever its case or hyphenation."
|
||||||
|
[elem & ks]
|
||||||
|
(let [attrs (normalize-attrs (:attrs elem))]
|
||||||
|
(some #(get attrs %) ks)))
|
||||||
|
|
||||||
|
(defn- clamp-unit
|
||||||
|
[v]
|
||||||
|
(-> v (max 0) (min 1)))
|
||||||
|
|
||||||
|
(defn- matrix-values
|
||||||
|
"The 20 values of an feColorMatrix of type matrix, else nil."
|
||||||
|
[elem]
|
||||||
|
(let [type (filter-attr elem :type)]
|
||||||
|
(when (or (nil? type) (= "matrix" type))
|
||||||
|
(let [values (some->> (filter-attr elem :values)
|
||||||
|
(re-seq #"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")
|
||||||
|
(mapv #(d/parse-double % 0)))]
|
||||||
|
(when (= 20 (count values))
|
||||||
|
values)))))
|
||||||
|
|
||||||
|
(defn- matrix->shadow-color
|
||||||
|
"The input is SourceAlpha, so only the constant RGB terms and alpha row count."
|
||||||
|
[values]
|
||||||
|
{:color (clr/rgb->hex (mapv #(mth/round (* 255 (clamp-unit (nth values %)))) [4 9 14]))
|
||||||
|
:opacity (clamp-unit (+ (nth values 18) (nth values 19)))})
|
||||||
|
|
||||||
|
(defn- flood->shadow-color
|
||||||
|
[elem]
|
||||||
|
(let [color (trim-fill-value (filter-attr elem :flood-color :floodcolor))
|
||||||
|
opacity (filter-attr elem :flood-opacity :floodopacity)]
|
||||||
|
{:color (if (clr/color-string? color) (clr/parse color) clr/black)
|
||||||
|
:opacity (if (some? opacity) (clamp-unit (parse-opacity opacity)) 1)}))
|
||||||
|
|
||||||
|
(defn- shadow-color
|
||||||
|
"Last color matrix or flood after the feOffset; earlier ones shape the silhouette."
|
||||||
|
[filter-content]
|
||||||
|
(let [source (->> filter-content
|
||||||
|
(drop-while #(not= :feOffset (:tag %)))
|
||||||
|
(filter #(contains? #{:feColorMatrix :feFlood} (:tag %)))
|
||||||
|
(last))]
|
||||||
|
(case (:tag source)
|
||||||
|
:feFlood (flood->shadow-color source)
|
||||||
|
:feColorMatrix (some-> (matrix-values source) matrix->shadow-color)
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
(defn- drop-shadow
|
||||||
|
[dx dy std-deviation color]
|
||||||
|
[{:id (uuid/next)
|
||||||
|
:style :drop-shadow
|
||||||
|
:offset-x dx
|
||||||
|
:offset-y dy
|
||||||
|
:blur (* 2 std-deviation)
|
||||||
|
:spread 0
|
||||||
|
:hidden false
|
||||||
|
:color color}])
|
||||||
|
|
||||||
(defn- build-drop-shadow
|
(defn- build-drop-shadow
|
||||||
[filter-content drop-shadow-elements]
|
[filter-content]
|
||||||
(let [offset-elem (find-filter-element filter-content :feOffset)]
|
(when-let [offset-elem (find-filter-element filter-content :feOffset)]
|
||||||
(when (and offset-elem (seq drop-shadow-elements))
|
(let [blur-elem (find-filter-element filter-content :feGaussianBlur)]
|
||||||
(let [blur-elem (find-filter-element drop-shadow-elements :feGaussianBlur)
|
(drop-shadow (d/parse-double (filter-attr offset-elem :dx) 0)
|
||||||
dx (-> (dm/get-in offset-elem [:attrs :dx])
|
(d/parse-double (filter-attr offset-elem :dy) 0)
|
||||||
(d/parse-double 0))
|
(d/parse-double (filter-attr blur-elem :stddeviation) 0)
|
||||||
dy (-> (dm/get-in offset-elem [:attrs :dy])
|
(or (shadow-color filter-content)
|
||||||
(d/parse-double 0))
|
{:color clr/black :opacity 1})))))
|
||||||
blur-value (if blur-elem
|
|
||||||
(-> (dm/get-in blur-elem [:attrs :stdDeviation])
|
(defn- build-fe-drop-shadow
|
||||||
(d/parse-double 0)
|
"dx, dy and stdDeviation default to 2."
|
||||||
(* 2))
|
[elem]
|
||||||
0)]
|
(drop-shadow (d/parse-double (filter-attr elem :dx) 2)
|
||||||
[{:id (uuid/next)
|
(d/parse-double (filter-attr elem :dy) 2)
|
||||||
:style :drop-shadow
|
(d/parse-double (filter-attr elem :stddeviation) 2)
|
||||||
:offset-x dx
|
(flood->shadow-color elem)))
|
||||||
: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
|
(defn apply-svg-filters
|
||||||
"Derives native blur/shadow from SVG filter definitions when the shape does
|
"Derives native blur/shadow from SVG filter definitions when the shape does
|
||||||
@ -442,12 +491,17 @@
|
|||||||
existing-shadow (:shadow shape)]
|
existing-shadow (:shadow shape)]
|
||||||
(if-let [filter-def (find-filter-def shape)]
|
(if-let [filter-def (find-filter-def shape)]
|
||||||
(let [content (:content filter-def)
|
(let [content (:content filter-def)
|
||||||
gaussian-blur (find-filter-element content :feGaussianBlur)
|
fe-drop-shadow (find-filter-element content :feDropShadow)
|
||||||
drop-shadow-elements (filter #(contains? drop-shadow-tags (:tag %)) content)
|
;; In a shadow chain the blur belongs to the shadow, not the shape.
|
||||||
|
shadow-chain? (or (some? fe-drop-shadow)
|
||||||
|
(some? (find-filter-element content :feOffset)))
|
||||||
|
gaussian-blur (when-not shadow-chain?
|
||||||
|
(find-filter-element content :feGaussianBlur))
|
||||||
blur (or existing-blur (build-blur gaussian-blur))
|
blur (or existing-blur (build-blur gaussian-blur))
|
||||||
shadow (if (seq existing-shadow)
|
shadow (cond
|
||||||
existing-shadow
|
(seq existing-shadow) existing-shadow
|
||||||
(build-drop-shadow content drop-shadow-elements))]
|
(some? fe-drop-shadow) (build-fe-drop-shadow fe-drop-shadow)
|
||||||
|
:else (build-drop-shadow content))]
|
||||||
(cond-> shape
|
(cond-> shape
|
||||||
blur (assoc :blur blur)
|
blur (assoc :blur blur)
|
||||||
(seq shadow) (assoc :shadow shadow)))
|
(seq shadow) (assoc :shadow shadow)))
|
||||||
|
|||||||
@ -59,6 +59,7 @@
|
|||||||
:feDiffuseLighting
|
:feDiffuseLighting
|
||||||
:feDisplacementMap
|
:feDisplacementMap
|
||||||
:feDistantLight
|
:feDistantLight
|
||||||
|
:feDropShadow
|
||||||
:feFlood
|
:feFlood
|
||||||
:feFuncA
|
:feFuncA
|
||||||
:feFuncB
|
:feFuncB
|
||||||
@ -439,6 +440,7 @@
|
|||||||
:feConvolveMatrix
|
:feConvolveMatrix
|
||||||
:feDiffuseLighting
|
:feDiffuseLighting
|
||||||
:feDisplacementMap
|
:feDisplacementMap
|
||||||
|
:feDropShadow
|
||||||
:feFlood
|
:feFlood
|
||||||
:feGaussianBlur
|
:feGaussianBlur
|
||||||
:feImage
|
:feImage
|
||||||
|
|||||||
@ -36,3 +36,8 @@
|
|||||||
:strokeDasharray "none",
|
:strokeDasharray "none",
|
||||||
:stopColor "#000000"}}))))
|
:stopColor "#000000"}}))))
|
||||||
|
|
||||||
|
(t/deftest extract-defs-keeps-fe-drop-shadow
|
||||||
|
(let [[defs _] (svg/extract-defs {:tag :filter
|
||||||
|
:attrs {:id "shadow"}
|
||||||
|
:content [{:tag :feDropShadow}]})]
|
||||||
|
(t/is (= [:feDropShadow] (mapv :tag (get-in defs ["shadow" :content]))))))
|
||||||
|
|||||||
@ -16,13 +16,22 @@
|
|||||||
:content [{:tag :feOffset :attrs {:dx "2" :dy "3"}}
|
:content [{:tag :feOffset :attrs {:dx "2" :dy "3"}}
|
||||||
{:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]}}})
|
{:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]}}})
|
||||||
|
|
||||||
(deftest derives-blur-and-shadow-from-svg-filter
|
(defn- filter-shape
|
||||||
|
[content]
|
||||||
|
{:svg-attrs {:filter "url(#f)"}
|
||||||
|
:svg-defs {"f" {:tag :filter :content content}}})
|
||||||
|
|
||||||
|
(defn- derived-shadow
|
||||||
|
[content]
|
||||||
|
(->> (svg-derived/apply-svg-filters (filter-shape content))
|
||||||
|
:shadow
|
||||||
|
(map #(dissoc % :id))))
|
||||||
|
|
||||||
|
(deftest derives-shadow-from-svg-filter
|
||||||
(let [shape (svg-derived/apply-svg-filters sample-filter-shape)
|
(let [shape (svg-derived/apply-svg-filters sample-filter-shape)
|
||||||
blur (:blur shape)
|
|
||||||
shadow (:shadow shape)]
|
shadow (:shadow shape)]
|
||||||
(testing "layer blur derived from feGaussianBlur"
|
(testing "the shadow's blur does not blur the shape"
|
||||||
(is (= :layer-blur (:type blur)))
|
(is (nil? (:blur shape))))
|
||||||
(is (= 4.0 (:value blur))))
|
|
||||||
(testing "drop shadow derived from filter chain"
|
(testing "drop shadow derived from filter chain"
|
||||||
(is (= [{:style :drop-shadow
|
(is (= [{:style :drop-shadow
|
||||||
:offset-x 2.0
|
:offset-x 2.0
|
||||||
@ -35,6 +44,64 @@
|
|||||||
(testing "svg attrs remain intact"
|
(testing "svg attrs remain intact"
|
||||||
(is (= "url(#simple-filter)" (get-in shape [:svg-attrs :filter]))))))
|
(is (= "url(#simple-filter)" (get-in shape [:svg-attrs :filter]))))))
|
||||||
|
|
||||||
|
(deftest derives-layer-blur-from-plain-blur-filter
|
||||||
|
(let [shape (svg-derived/apply-svg-filters
|
||||||
|
(filter-shape [{:tag :feGaussianBlur :attrs {:stdDeviation "4"}}]))]
|
||||||
|
(is (= :layer-blur (get-in shape [:blur :type])))
|
||||||
|
(is (= 4.0 (get-in shape [:blur :value])))
|
||||||
|
(is (nil? (:shadow shape)))))
|
||||||
|
|
||||||
|
(deftest shadow-color-from-color-matrix
|
||||||
|
(is (= [{:color "#ff0000" :opacity 0.3}]
|
||||||
|
(map :color (derived-shadow
|
||||||
|
[{:tag :feOffset :attrs {:in "SourceAlpha" :dx "12" :dy "12"}}
|
||||||
|
{:tag :feGaussianBlur :attrs {:stdDeviation "4"}}
|
||||||
|
{:tag :feColorMatrix
|
||||||
|
:attrs {:type "matrix"
|
||||||
|
:values "0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0"}}])))))
|
||||||
|
|
||||||
|
(deftest shadow-color-ignores-hard-alpha-matrix-before-offset
|
||||||
|
;; Figma: a hard-alpha matrix before the offset, the color matrix after it.
|
||||||
|
(is (= [{:color "#000000" :opacity 0.25}]
|
||||||
|
(map :color (derived-shadow
|
||||||
|
[{:tag :feFlood :attrs {:flood-opacity "0"}}
|
||||||
|
{:tag :feColorMatrix
|
||||||
|
:attrs {:in "SourceAlpha"
|
||||||
|
:type "matrix"
|
||||||
|
:values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"}}
|
||||||
|
{:tag :feOffset :attrs {:dy "4"}}
|
||||||
|
{:tag :feGaussianBlur :attrs {:stdDeviation "2"}}
|
||||||
|
{:tag :feColorMatrix
|
||||||
|
:attrs {:type "matrix"
|
||||||
|
:values "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"}}])))))
|
||||||
|
|
||||||
|
(deftest shadow-color-from-flood
|
||||||
|
(is (= [{:color "#00ff00" :opacity 0.5}]
|
||||||
|
(map :color (derived-shadow
|
||||||
|
[{:tag :feOffset :attrs {:dx "2" :dy "2"}}
|
||||||
|
{:tag :feGaussianBlur :attrs {:stdDeviation "2"}}
|
||||||
|
{:tag :feFlood :attrs {:flood-color "#00ff00" :flood-opacity "0.5"}}])))))
|
||||||
|
|
||||||
|
(deftest derives-shadow-from-fe-drop-shadow
|
||||||
|
(let [content [{:tag :feDropShadow
|
||||||
|
:attrs {:dx "12" :dy "8" :stdDeviation "4"
|
||||||
|
:flood-color "#000" :flood-opacity "0.3"}}]
|
||||||
|
shape (svg-derived/apply-svg-filters (filter-shape content))]
|
||||||
|
(is (nil? (:blur shape)))
|
||||||
|
(is (= [{:style :drop-shadow
|
||||||
|
:offset-x 12
|
||||||
|
:offset-y 8
|
||||||
|
:blur 8
|
||||||
|
:spread 0
|
||||||
|
:hidden false
|
||||||
|
:color {:color "#000000" :opacity 0.3}}]
|
||||||
|
(derived-shadow content)))))
|
||||||
|
|
||||||
|
(deftest fe-drop-shadow-uses-spec-defaults
|
||||||
|
(is (= [{:offset-x 2 :offset-y 2 :blur 4 :color {:color "#000000" :opacity 1}}]
|
||||||
|
(map #(select-keys % [:offset-x :offset-y :blur :color])
|
||||||
|
(derived-shadow [{:tag :feDropShadow :attrs {}}])))))
|
||||||
|
|
||||||
(deftest keeps-existing-native-filters
|
(deftest keeps-existing-native-filters
|
||||||
(let [existing {:blur {:id :existing :type :layer-blur :value 1.0}
|
(let [existing {:blur {:id :existing :type :layer-blur :value 1.0}
|
||||||
:shadow [{:id :shadow :style :drop-shadow}]}
|
:shadow [{:id :shadow :style :drop-shadow}]}
|
||||||
|
|||||||
@ -2073,14 +2073,7 @@ impl RenderState {
|
|||||||
|
|
||||||
let shape = &shape;
|
let shape = &shape;
|
||||||
|
|
||||||
if shape.fills.is_empty()
|
if shape.inherits_fills() {
|
||||||
&& !matches!(shape.shape_type, Type::Group(_))
|
|
||||||
&& !matches!(shape.shape_type, Type::Frame(_))
|
|
||||||
&& !shape
|
|
||||||
.svg_attrs
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|attrs| attrs.fill_none)
|
|
||||||
{
|
|
||||||
if let Some(fills_to_render) = self.nested_fills.last() {
|
if let Some(fills_to_render) = self.nested_fills.last() {
|
||||||
let fills_to_render = fills_to_render.clone();
|
let fills_to_render = fills_to_render.clone();
|
||||||
fills::render(
|
fills::render(
|
||||||
@ -2754,6 +2747,7 @@ impl RenderState {
|
|||||||
// FIXME
|
// FIXME
|
||||||
return Ok((Vec::new(), 0, 0));
|
return Ok((Vec::new(), 0, 0));
|
||||||
};
|
};
|
||||||
|
self.nested_fills.push(shape.inherited_fills(tree));
|
||||||
let mut extrect = shape.extrect(tree, scale);
|
let mut extrect = shape.extrect(tree, scale);
|
||||||
self.export_context = Some((extrect, scale));
|
self.export_context = Some((extrect, scale));
|
||||||
let margins = self.surfaces.margins;
|
let margins = self.surfaces.margins;
|
||||||
|
|||||||
@ -62,24 +62,14 @@ impl SvgLayerCanvas {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fills to paint for a leaf: own fills, else inherited group fills (unless
|
/// Fills to paint for a leaf: own fills, else inherited group fills.
|
||||||
/// `fill="none"` broke the SVG inheritance chain). Mirrors GPU nested_fills.
|
/// Owned so callers can still mutably borrow `self` afterward.
|
||||||
/// Returns an owned vec so callers can still mutably borrow `self` afterward.
|
|
||||||
pub(super) fn effective_fills_owned(&self, element: &Shape) -> Vec<Fill> {
|
pub(super) fn effective_fills_owned(&self, element: &Shape) -> Vec<Fill> {
|
||||||
if !element.fills.is_empty() {
|
if element.inherits_fills() {
|
||||||
return element.fills.clone();
|
self.nested_fills.last().cloned().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
element.fills.clone()
|
||||||
}
|
}
|
||||||
if matches!(element.shape_type, Type::Group(_) | Type::Frame(_)) {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
if element
|
|
||||||
.svg_attrs
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|attrs| attrs.fill_none)
|
|
||||||
{
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
self.nested_fills.last().cloned().unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CTM for silhouette geometry: original centered transform, then local
|
/// CTM for silhouette geometry: original centered transform, then local
|
||||||
|
|||||||
@ -159,6 +159,9 @@ fn render_body(
|
|||||||
ty: f32,
|
ty: f32,
|
||||||
) -> Result<(String, String)> {
|
) -> Result<(String, String)> {
|
||||||
let mut builder = SvgLayerCanvas::new(scale, page_rect, tx, ty);
|
let mut builder = SvgLayerCanvas::new(scale, page_rect, tx, ty);
|
||||||
|
if let Some(shape) = tree.get(id) {
|
||||||
|
builder.nested_fills.push(shape.inherited_fills(tree));
|
||||||
|
}
|
||||||
render_tree(&mut builder, shared, id, tree, scale)?;
|
render_tree(&mut builder, shared, id, tree, scale)?;
|
||||||
builder.flush();
|
builder.flush();
|
||||||
Ok((builder.defs, builder.out))
|
Ok((builder.defs, builder.out))
|
||||||
|
|||||||
@ -3563,3 +3563,96 @@ fn a_lone_per_side_stroke_is_not_mitered() {
|
|||||||
"one stroke has nothing to miter against, so it stays a plain band: {svg}"
|
"one stroke has nothing to miter against, so it stays a plain band: {svg}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INHERITED_GROUP_COLOR: skia::Color = skia::Color::from_rgb(17, 34, 52);
|
||||||
|
|
||||||
|
fn group_with_empty_path(group_fills: Vec<Fill>) -> ShapesPool {
|
||||||
|
let mut pool = ShapesPool::new();
|
||||||
|
add_group(
|
||||||
|
&mut pool,
|
||||||
|
uid(1),
|
||||||
|
Uuid::nil(),
|
||||||
|
(0.0, 0.0, 100.0, 100.0),
|
||||||
|
&[uid(2)],
|
||||||
|
);
|
||||||
|
pool.get_mut(&uid(1)).unwrap().set_fills(group_fills);
|
||||||
|
add_empty_fill_closed_path(&mut pool, uid(2), uid(1), (10.0, 10.0, 90.0, 90.0));
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raster_png(pool: &ShapesPool, id: Uuid) -> Vec<u8> {
|
||||||
|
let mut resources =
|
||||||
|
crate::render::RenderResources::try_new_headless().expect("headless resources");
|
||||||
|
let (bytes, _, _) = crate::render::raster::render_to_raster(
|
||||||
|
&mut resources,
|
||||||
|
&id,
|
||||||
|
pool,
|
||||||
|
1.0,
|
||||||
|
crate::render::raster::RasterFormat::Png,
|
||||||
|
)
|
||||||
|
.expect("raster export");
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exported_child_inherits_ancestor_group_fill() {
|
||||||
|
let pool = group_with_empty_path(vec![Fill::Solid(SolidColor(INHERITED_GROUP_COLOR))]);
|
||||||
|
|
||||||
|
let svg = render(&pool, uid(2));
|
||||||
|
assert!(
|
||||||
|
svg.to_ascii_lowercase().contains("fill=\"#112234\""),
|
||||||
|
"child export must paint the inherited group fill: {svg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exported_child_does_not_inherit_past_a_frame() {
|
||||||
|
let mut pool = ShapesPool::new();
|
||||||
|
let group_id = uid(1);
|
||||||
|
let frame_id = uid(2);
|
||||||
|
let path_id = uid(3);
|
||||||
|
|
||||||
|
add_group(
|
||||||
|
&mut pool,
|
||||||
|
group_id,
|
||||||
|
Uuid::nil(),
|
||||||
|
(0.0, 0.0, 100.0, 100.0),
|
||||||
|
&[frame_id],
|
||||||
|
);
|
||||||
|
pool.get_mut(&group_id)
|
||||||
|
.unwrap()
|
||||||
|
.set_fills(vec![Fill::Solid(SolidColor(INHERITED_GROUP_COLOR))]);
|
||||||
|
add_frame(
|
||||||
|
&mut pool,
|
||||||
|
frame_id,
|
||||||
|
group_id,
|
||||||
|
(0.0, 0.0, 100.0, 100.0),
|
||||||
|
skia::Color::WHITE,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let frame = pool.get_mut(&frame_id).unwrap();
|
||||||
|
frame.set_fills(vec![]);
|
||||||
|
frame.add_child(path_id);
|
||||||
|
}
|
||||||
|
add_empty_fill_closed_path(&mut pool, path_id, frame_id, (10.0, 10.0, 90.0, 90.0));
|
||||||
|
|
||||||
|
let svg = render(&pool, path_id);
|
||||||
|
assert!(
|
||||||
|
!svg.to_ascii_lowercase().contains("#112234"),
|
||||||
|
"a frame breaks group fill inheritance: {svg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raster_export_of_child_paints_inherited_group_fill() {
|
||||||
|
let fill = Fill::Solid(SolidColor(INHERITED_GROUP_COLOR));
|
||||||
|
let inherited = group_with_empty_path(vec![fill.clone()]);
|
||||||
|
let mut own = group_with_empty_path(vec![]);
|
||||||
|
own.get_mut(&uid(2)).unwrap().set_fills(vec![fill]);
|
||||||
|
let bare = group_with_empty_path(vec![]);
|
||||||
|
|
||||||
|
let inherited_png = raster_png(&inherited, uid(2));
|
||||||
|
assert_eq!(inherited_png, raster_png(&own, uid(2)));
|
||||||
|
assert_ne!(inherited_png, raster_png(&bare, uid(2)));
|
||||||
|
}
|
||||||
|
|||||||
@ -422,6 +422,32 @@ struct TreeOpts<'a> {
|
|||||||
/// When rendering a backdrop, the shape whose own subtree must be omitted
|
/// When rendering a backdrop, the shape whose own subtree must be omitted
|
||||||
/// (so the blur samples only what is *behind* it).
|
/// (so the blur samples only what is *behind* it).
|
||||||
skip: Option<&'a Uuid>,
|
skip: Option<&'a Uuid>,
|
||||||
|
/// Fills the root inherits from its ancestors; backdrops restart from it.
|
||||||
|
root_fills: &'a [Fill],
|
||||||
|
/// Nearest group's fills, painted by fill-less leaves (GPU `nested_fills`).
|
||||||
|
inherited_fills: &'a [Fill],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TreeOpts<'a> {
|
||||||
|
/// Options for `container`'s children: a group hands its fills down, a
|
||||||
|
/// frame breaks the inheritance.
|
||||||
|
fn for_children_of<'b>(&self, container: &'b Shape) -> TreeOpts<'b>
|
||||||
|
where
|
||||||
|
'a: 'b,
|
||||||
|
{
|
||||||
|
let inherited_fills: &'b [Fill] = match container.shape_type {
|
||||||
|
Type::Group(_) => &container.fills,
|
||||||
|
_ => &[],
|
||||||
|
};
|
||||||
|
TreeOpts {
|
||||||
|
root: self.root,
|
||||||
|
page: self.page,
|
||||||
|
embed_bg_blur: self.embed_bg_blur,
|
||||||
|
skip: self.skip,
|
||||||
|
root_fills: self.root_fills,
|
||||||
|
inherited_fills,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Depth-first render of the shape tree rooted at `id`. Used for raster export
|
/// Depth-first render of the shape tree rooted at `id`. Used for raster export
|
||||||
@ -463,11 +489,17 @@ fn render_tree_dispatch(
|
|||||||
page: skia::Rect,
|
page: skia::Rect,
|
||||||
embed_bg_blur: bool,
|
embed_bg_blur: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let root_fills = tree
|
||||||
|
.get(id)
|
||||||
|
.map(|shape| shape.inherited_fills(tree))
|
||||||
|
.unwrap_or_default();
|
||||||
let opts = TreeOpts {
|
let opts = TreeOpts {
|
||||||
root: id,
|
root: id,
|
||||||
page,
|
page,
|
||||||
embed_bg_blur,
|
embed_bg_blur,
|
||||||
skip: None,
|
skip: None,
|
||||||
|
root_fills: &root_fills,
|
||||||
|
inherited_fills: &root_fills,
|
||||||
};
|
};
|
||||||
render_tree_inner(shared, canvas, id, tree, scale, &opts)
|
render_tree_inner(shared, canvas, id, tree, scale, &opts)
|
||||||
}
|
}
|
||||||
@ -524,7 +556,12 @@ fn render_tree_inner(
|
|||||||
| Type::Bool(_)
|
| Type::Bool(_)
|
||||||
| Type::Text(_)
|
| Type::Text(_)
|
||||||
| Type::SVGRaw(_) => {
|
| Type::SVGRaw(_) => {
|
||||||
render_leaf(shared, canvas, element, scale)?;
|
let fills = if element.inherits_fills() {
|
||||||
|
opts.inherited_fills
|
||||||
|
} else {
|
||||||
|
element.fills.as_slice()
|
||||||
|
};
|
||||||
|
render_leaf(shared, canvas, element, fills, scale)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -657,6 +694,8 @@ fn render_background_blur_image(
|
|||||||
page: opts.page,
|
page: opts.page,
|
||||||
embed_bg_blur: false,
|
embed_bg_blur: false,
|
||||||
skip: Some(&shape.id),
|
skip: Some(&shape.id),
|
||||||
|
root_fills: opts.root_fills,
|
||||||
|
inherited_fills: opts.root_fills,
|
||||||
};
|
};
|
||||||
render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?;
|
render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?;
|
||||||
}
|
}
|
||||||
@ -779,6 +818,7 @@ fn render_group(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
||||||
|
let child_opts = opts.for_children_of(element);
|
||||||
|
|
||||||
if masked {
|
if masked {
|
||||||
// Mirror the GPU mask: render all children (including the mask shape)
|
// Mirror the GPU mask: render all children (including the mask shape)
|
||||||
@ -793,7 +833,7 @@ fn render_group(
|
|||||||
);
|
);
|
||||||
|
|
||||||
for child_id in &children {
|
for child_id in &children {
|
||||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(mask_id) = element.mask_id() {
|
if let Some(mask_id) = element.mask_id() {
|
||||||
@ -804,14 +844,14 @@ fn render_group(
|
|||||||
.bounds(&subtree_bounds)
|
.bounds(&subtree_bounds)
|
||||||
.paint(&mask_paint),
|
.paint(&mask_paint),
|
||||||
);
|
);
|
||||||
render_tree_inner(shared, canvas, mask_id, tree, scale, opts)?;
|
render_tree_inner(shared, canvas, mask_id, tree, scale, &child_opts)?;
|
||||||
canvas.restore(); // mask layer
|
canvas.restore(); // mask layer
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.restore(); // composition layer
|
canvas.restore(); // composition layer
|
||||||
} else {
|
} else {
|
||||||
for child_id in &children {
|
for child_id in &children {
|
||||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -895,8 +935,9 @@ fn render_frame(
|
|||||||
|
|
||||||
// Children (absolute coords, no frame transform).
|
// Children (absolute coords, no frame transform).
|
||||||
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
||||||
|
let child_opts = opts.for_children_of(element);
|
||||||
for child_id in &children {
|
for child_id in &children {
|
||||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?;
|
||||||
}
|
}
|
||||||
canvas.restore(); // content clip
|
canvas.restore(); // content clip
|
||||||
|
|
||||||
@ -948,8 +989,9 @@ fn render_container_drop_shadows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
let children: Vec<Uuid> = element.children_ids_iter_forward(false).copied().collect();
|
||||||
|
let child_opts = opts.for_children_of(element);
|
||||||
for child_id in &children {
|
for child_id in &children {
|
||||||
render_tree_inner(shared, canvas, child_id, tree, scale, opts)?;
|
render_tree_inner(shared, canvas, child_id, tree, scale, &child_opts)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.restore();
|
canvas.restore();
|
||||||
@ -965,6 +1007,7 @@ fn render_leaf(
|
|||||||
shared: &mut RenderResources,
|
shared: &mut RenderResources,
|
||||||
canvas: &Canvas,
|
canvas: &Canvas,
|
||||||
element: &Shape,
|
element: &Shape,
|
||||||
|
fills: &[Fill],
|
||||||
scale: f32,
|
scale: f32,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let needs_layer = element.needs_layer();
|
let needs_layer = element.needs_layer();
|
||||||
@ -996,7 +1039,7 @@ fn render_leaf(
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderer.draw_drop_shadows(element)?;
|
renderer.draw_drop_shadows(element)?;
|
||||||
render_leaf_content(&mut renderer, element)?;
|
render_leaf_content(&mut renderer, element, fills)?;
|
||||||
|
|
||||||
if blur_layer {
|
if blur_layer {
|
||||||
renderer.restore_blur_layer();
|
renderer.restore_blur_layer();
|
||||||
@ -1016,6 +1059,7 @@ fn render_leaf(
|
|||||||
pub(super) fn render_leaf_content<R: ShapeRenderer + ?Sized>(
|
pub(super) fn render_leaf_content<R: ShapeRenderer + ?Sized>(
|
||||||
renderer: &mut R,
|
renderer: &mut R,
|
||||||
shape: &Shape,
|
shape: &Shape,
|
||||||
|
fills: &[Fill],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
match &shape.shape_type {
|
match &shape.shape_type {
|
||||||
Type::Text(_) => renderer.draw_text(shape)?,
|
Type::Text(_) => renderer.draw_text(shape)?,
|
||||||
@ -1027,7 +1071,7 @@ pub(super) fn render_leaf_content<R: ShapeRenderer + ?Sized>(
|
|||||||
| Type::Bool(_)
|
| Type::Bool(_)
|
||||||
| Type::Group(_)
|
| Type::Group(_)
|
||||||
| Type::Frame(_) => {
|
| Type::Frame(_) => {
|
||||||
renderer.draw_fills(shape, &shape.fills)?;
|
renderer.draw_fills(shape, fills)?;
|
||||||
renderer.draw_fill_inner_shadows(shape)?;
|
renderer.draw_fill_inner_shadows(shape)?;
|
||||||
|
|
||||||
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
|
let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect();
|
||||||
|
|||||||
@ -1818,6 +1818,31 @@ impl Shape {
|
|||||||
!self.fills.is_empty()
|
!self.fills.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this fill-less leaf paints its group's fills (SVG inheritance,
|
||||||
|
/// broken by `fill="none"`).
|
||||||
|
pub fn inherits_fills(&self) -> bool {
|
||||||
|
self.fills.is_empty()
|
||||||
|
&& !matches!(self.shape_type, Type::Group(_) | Type::Frame(_))
|
||||||
|
&& !self.svg_attrs.as_ref().is_some_and(|attrs| attrs.fill_none)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fills a fill-less child inherits when rendering starts at this shape:
|
||||||
|
/// the nearest group's fills, or none past a frame (seeds `nested_fills`).
|
||||||
|
pub fn inherited_fills(&self, shapes: ShapesPoolRef) -> Vec<Fill> {
|
||||||
|
let mut parent_id = self.parent_id;
|
||||||
|
while let Some(id) = parent_id {
|
||||||
|
let Some(parent) = shapes.get(&id) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
match parent.shape_type {
|
||||||
|
Type::Group(_) => return parent.fills.clone(),
|
||||||
|
Type::Frame(_) => break,
|
||||||
|
_ => parent_id = parent.parent_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
/// Determines if this frame or group can be flattened (doesn't affect children visually)
|
/// Determines if this frame or group can be flattened (doesn't affect children visually)
|
||||||
/// A container can be flattened if it has no visual effects that affect its children
|
/// A container can be flattened if it has no visual effects that affect its children
|
||||||
/// and doesn't render its own content (no fills/strokes)
|
/// and doesn't render its own content (no fills/strokes)
|
||||||
@ -2059,10 +2084,7 @@ impl Shape {
|
|||||||
/// contribute to the shadow silhouette, so frames with outer/center strokes can
|
/// contribute to the shadow silhouette, so frames with outer/center strokes can
|
||||||
/// look slightly narrower here. We keep them eligible anyway for performance.
|
/// look slightly narrower here. We keep them eligible anyway for performance.
|
||||||
pub fn uses_direct_container_drop_shadow(&self, tree: ShapesPoolRef) -> bool {
|
pub fn uses_direct_container_drop_shadow(&self, tree: ShapesPoolRef) -> bool {
|
||||||
if !matches!(self.shape_type, Type::Frame(_)) {
|
if !self.is_filled_frame() {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if !self.has_fills() {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if self.blend_mode() != BlendMode::default() {
|
if self.blend_mode() != BlendMode::default() {
|
||||||
@ -2085,7 +2107,11 @@ impl Shape {
|
|||||||
/// When true, the container's own fill shadow mask is enough and descendant
|
/// When true, the container's own fill shadow mask is enough and descendant
|
||||||
/// silhouettes can be skipped (same geometry assumption as the direct path).
|
/// silhouettes can be skipped (same geometry assumption as the direct path).
|
||||||
pub fn container_fill_covers_shadow_descendants(&self, tree: ShapesPoolRef) -> bool {
|
pub fn container_fill_covers_shadow_descendants(&self, tree: ShapesPoolRef) -> bool {
|
||||||
self.has_fills() && self.descendants_contained_for_frame_shadow(tree, self.selrect())
|
self.is_filled_frame() && self.descendants_contained_for_frame_shadow(tree, self.selrect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_filled_frame(&self) -> bool {
|
||||||
|
matches!(self.shape_type, Type::Frame(_)) && self.has_fills()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn descendants_have_drop_shadows(&self, tree: ShapesPoolRef) -> bool {
|
fn descendants_have_drop_shadows(&self, tree: ShapesPoolRef) -> bool {
|
||||||
@ -2695,6 +2721,17 @@ mod tests {
|
|||||||
assert!(!frame.container_fill_covers_shadow_descendants(&pool));
|
assert!(!frame.container_fill_covers_shadow_descendants(&pool));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filled_group_does_not_cover_shadow_descendants() {
|
||||||
|
let (mut pool, group_id) =
|
||||||
|
frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::BLACK)), 1.0);
|
||||||
|
pool.get_mut(&group_id)
|
||||||
|
.expect("group")
|
||||||
|
.set_shape_type(Type::Group(Group { masked: false }));
|
||||||
|
let group = pool.get(&group_id).expect("group");
|
||||||
|
assert!(!group.container_fill_covers_shadow_descendants(&pool));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn frame_with_contained_child_covers_shadow_descendants() {
|
fn frame_with_contained_child_covers_shadow_descendants() {
|
||||||
let (pool, frame_id) =
|
let (pool, frame_id) =
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user