From c44484a1e5be0e7cf7c486b2f82db0191f72468c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Mon, 28 Sep 2026 07:29:25 +0200 Subject: [PATCH] :bug: Fix SVG filter shadows and inherited group fills in render-wasm: (#11925) * :bug: Fix shadows derived from imported SVG filters * :bug: Fix inherited group fills in exports and group drop shadows * :wrench: Run exporter tests on render-wasm changes --- .github/workflows/tests-exporter.yml | 2 + .../app/common/render_wasm/svg_derived.cljs | 114 +++++++++++++----- common/src/app/common/svg.cljc | 2 + common/test/common_tests/svg_test.cljc | 5 + .../test/frontend_tests/svg_filters_test.cljs | 77 +++++++++++- render-wasm/src/render.rs | 10 +- render-wasm/src/render/svg/document.rs | 22 +--- render-wasm/src/render/svg/mod.rs | 3 + render-wasm/src/render/svg/tests.rs | 93 ++++++++++++++ render-wasm/src/render/vector.rs | 60 +++++++-- render-wasm/src/shapes.rs | 47 +++++++- 11 files changed, 363 insertions(+), 72 deletions(-) diff --git a/.github/workflows/tests-exporter.yml b/.github/workflows/tests-exporter.yml index 0c8317499c..aefbfc1dbc 100644 --- a/.github/workflows/tests-exporter.yml +++ b/.github/workflows/tests-exporter.yml @@ -9,6 +9,7 @@ on: paths: - 'exporter/**' - 'common/**' + - 'render-wasm/**' types: - opened @@ -23,6 +24,7 @@ on: paths: - 'exporter/**' - 'common/**' + - 'render-wasm/**' concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/common/src/app/common/render_wasm/svg_derived.cljs b/common/src/app/common/render_wasm/svg_derived.cljs index 314f91f195..f93a07bdf3 100644 --- a/common/src/app/common/render_wasm/svg_derived.cljs +++ b/common/src/app/common/render_wasm/svg_derived.cljs @@ -11,6 +11,7 @@ [app.common.data.macros :as dm] [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] + [app.common.math :as mth] [app.common.svg :as csvg] [app.common.types.color :as clr] [app.common.uuid :as uuid] @@ -382,9 +383,6 @@ [{:fill-color "#000000" :fill-opacity 1}] :else []))) -(def ^:private drop-shadow-tags - #{:feOffset :feGaussianBlur :feColorMatrix}) - (defn- find-filter-element "Finds a filter element by tag in filter content." [filter-content tag] @@ -409,29 +407,80 @@ (d/parse-double 0)) :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 - [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}}])))) + [filter-content] + (when-let [offset-elem (find-filter-element filter-content :feOffset)] + (let [blur-elem (find-filter-element filter-content :feGaussianBlur)] + (drop-shadow (d/parse-double (filter-attr offset-elem :dx) 0) + (d/parse-double (filter-attr offset-elem :dy) 0) + (d/parse-double (filter-attr blur-elem :stddeviation) 0) + (or (shadow-color filter-content) + {:color clr/black :opacity 1}))))) + +(defn- build-fe-drop-shadow + "dx, dy and stdDeviation default to 2." + [elem] + (drop-shadow (d/parse-double (filter-attr elem :dx) 2) + (d/parse-double (filter-attr elem :dy) 2) + (d/parse-double (filter-attr elem :stddeviation) 2) + (flood->shadow-color elem))) (defn apply-svg-filters "Derives native blur/shadow from SVG filter definitions when the shape does @@ -442,12 +491,17 @@ 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) + fe-drop-shadow (find-filter-element content :feDropShadow) + ;; 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)) - shadow (if (seq existing-shadow) - existing-shadow - (build-drop-shadow content drop-shadow-elements))] + shadow (cond + (seq existing-shadow) existing-shadow + (some? fe-drop-shadow) (build-fe-drop-shadow fe-drop-shadow) + :else (build-drop-shadow content))] (cond-> shape blur (assoc :blur blur) (seq shadow) (assoc :shadow shadow))) diff --git a/common/src/app/common/svg.cljc b/common/src/app/common/svg.cljc index 313c2e674f..e51cad731e 100644 --- a/common/src/app/common/svg.cljc +++ b/common/src/app/common/svg.cljc @@ -59,6 +59,7 @@ :feDiffuseLighting :feDisplacementMap :feDistantLight + :feDropShadow :feFlood :feFuncA :feFuncB @@ -439,6 +440,7 @@ :feConvolveMatrix :feDiffuseLighting :feDisplacementMap + :feDropShadow :feFlood :feGaussianBlur :feImage diff --git a/common/test/common_tests/svg_test.cljc b/common/test/common_tests/svg_test.cljc index ed2c345037..a8c8aa17e2 100644 --- a/common/test/common_tests/svg_test.cljc +++ b/common/test/common_tests/svg_test.cljc @@ -36,3 +36,8 @@ :strokeDasharray "none", :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])))))) diff --git a/frontend/test/frontend_tests/svg_filters_test.cljs b/frontend/test/frontend_tests/svg_filters_test.cljs index a8b1fbb90b..b1eb4be721 100644 --- a/frontend/test/frontend_tests/svg_filters_test.cljs +++ b/frontend/test/frontend_tests/svg_filters_test.cljs @@ -16,13 +16,22 @@ :content [{:tag :feOffset :attrs {:dx "2" :dy "3"}} {: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) - blur (:blur shape) shadow (:shadow shape)] - (testing "layer blur derived from feGaussianBlur" - (is (= :layer-blur (:type blur))) - (is (= 4.0 (:value blur)))) + (testing "the shadow's blur does not blur the shape" + (is (nil? (:blur shape)))) (testing "drop shadow derived from filter chain" (is (= [{:style :drop-shadow :offset-x 2.0 @@ -35,6 +44,64 @@ (testing "svg attrs remain intact" (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 (let [existing {:blur {:id :existing :type :layer-blur :value 1.0} :shadow [{:id :shadow :style :drop-shadow}]} diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 999972bfcd..b840563f4f 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2073,14 +2073,7 @@ impl RenderState { let shape = &shape; - if shape.fills.is_empty() - && !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 shape.inherits_fills() { if let Some(fills_to_render) = self.nested_fills.last() { let fills_to_render = fills_to_render.clone(); fills::render( @@ -2754,6 +2747,7 @@ impl RenderState { // FIXME return Ok((Vec::new(), 0, 0)); }; + self.nested_fills.push(shape.inherited_fills(tree)); let mut extrect = shape.extrect(tree, scale); self.export_context = Some((extrect, scale)); let margins = self.surfaces.margins; diff --git a/render-wasm/src/render/svg/document.rs b/render-wasm/src/render/svg/document.rs index 2de25b553d..1fbdd11615 100644 --- a/render-wasm/src/render/svg/document.rs +++ b/render-wasm/src/render/svg/document.rs @@ -62,24 +62,14 @@ impl SvgLayerCanvas { } } - /// Fills to paint for a leaf: own fills, else inherited group fills (unless - /// `fill="none"` broke the SVG inheritance chain). Mirrors GPU nested_fills. - /// Returns an owned vec so callers can still mutably borrow `self` afterward. + /// Fills to paint for a leaf: own fills, else inherited group fills. + /// Owned so callers can still mutably borrow `self` afterward. pub(super) fn effective_fills_owned(&self, element: &Shape) -> Vec { - if !element.fills.is_empty() { - return element.fills.clone(); + if element.inherits_fills() { + 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 diff --git a/render-wasm/src/render/svg/mod.rs b/render-wasm/src/render/svg/mod.rs index 6fb7207c50..ae4fdd3c08 100644 --- a/render-wasm/src/render/svg/mod.rs +++ b/render-wasm/src/render/svg/mod.rs @@ -159,6 +159,9 @@ fn render_body( ty: f32, ) -> Result<(String, String)> { 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)?; builder.flush(); Ok((builder.defs, builder.out)) diff --git a/render-wasm/src/render/svg/tests.rs b/render-wasm/src/render/svg/tests.rs index 5643615e85..2f5e5ff8e6 100644 --- a/render-wasm/src/render/svg/tests.rs +++ b/render-wasm/src/render/svg/tests.rs @@ -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}" ); } + +const INHERITED_GROUP_COLOR: skia::Color = skia::Color::from_rgb(17, 34, 52); + +fn group_with_empty_path(group_fills: Vec) -> 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 { + 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))); +} diff --git a/render-wasm/src/render/vector.rs b/render-wasm/src/render/vector.rs index df1135108e..c04be04683 100644 --- a/render-wasm/src/render/vector.rs +++ b/render-wasm/src/render/vector.rs @@ -422,6 +422,32 @@ struct TreeOpts<'a> { /// When rendering a backdrop, the shape whose own subtree must be omitted /// (so the blur samples only what is *behind* it). 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 @@ -463,11 +489,17 @@ fn render_tree_dispatch( page: skia::Rect, embed_bg_blur: bool, ) -> Result<()> { + let root_fills = tree + .get(id) + .map(|shape| shape.inherited_fills(tree)) + .unwrap_or_default(); let opts = TreeOpts { root: id, page, embed_bg_blur, skip: None, + root_fills: &root_fills, + inherited_fills: &root_fills, }; render_tree_inner(shared, canvas, id, tree, scale, &opts) } @@ -524,7 +556,12 @@ fn render_tree_inner( | Type::Bool(_) | Type::Text(_) | 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, embed_bg_blur: false, skip: Some(&shape.id), + root_fills: opts.root_fills, + inherited_fills: opts.root_fills, }; render_tree_inner(shared, oc, opts.root, tree, scale, &sub)?; } @@ -779,6 +818,7 @@ fn render_group( } let children: Vec = element.children_ids_iter_forward(false).copied().collect(); + let child_opts = opts.for_children_of(element); if masked { // Mirror the GPU mask: render all children (including the mask shape) @@ -793,7 +833,7 @@ fn render_group( ); 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() { @@ -804,14 +844,14 @@ fn render_group( .bounds(&subtree_bounds) .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(); // composition layer } else { 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). let children: Vec = element.children_ids_iter_forward(false).copied().collect(); + let child_opts = opts.for_children_of(element); 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 @@ -948,8 +989,9 @@ fn render_container_drop_shadows( } let children: Vec = element.children_ids_iter_forward(false).copied().collect(); + let child_opts = opts.for_children_of(element); 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(); @@ -965,6 +1007,7 @@ fn render_leaf( shared: &mut RenderResources, canvas: &Canvas, element: &Shape, + fills: &[Fill], scale: f32, ) -> Result<()> { let needs_layer = element.needs_layer(); @@ -996,7 +1039,7 @@ fn render_leaf( }; renderer.draw_drop_shadows(element)?; - render_leaf_content(&mut renderer, element)?; + render_leaf_content(&mut renderer, element, fills)?; if blur_layer { renderer.restore_blur_layer(); @@ -1016,6 +1059,7 @@ fn render_leaf( pub(super) fn render_leaf_content( renderer: &mut R, shape: &Shape, + fills: &[Fill], ) -> Result<()> { match &shape.shape_type { Type::Text(_) => renderer.draw_text(shape)?, @@ -1027,7 +1071,7 @@ pub(super) fn render_leaf_content( | Type::Bool(_) | Type::Group(_) | Type::Frame(_) => { - renderer.draw_fills(shape, &shape.fills)?; + renderer.draw_fills(shape, fills)?; renderer.draw_fill_inner_shadows(shape)?; let visible_strokes: Vec<&Stroke> = shape.visible_strokes().collect(); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 3e67689dc1..940e3d724b 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1818,6 +1818,31 @@ impl Shape { !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 { + 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) /// 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) @@ -2059,10 +2084,7 @@ impl Shape { /// contribute to the shadow silhouette, so frames with outer/center strokes can /// look slightly narrower here. We keep them eligible anyway for performance. pub fn uses_direct_container_drop_shadow(&self, tree: ShapesPoolRef) -> bool { - if !matches!(self.shape_type, Type::Frame(_)) { - return false; - } - if !self.has_fills() { + if !self.is_filled_frame() { return false; } 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 /// silhouettes can be skipped (same geometry assumption as the direct path). 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 { @@ -2695,6 +2721,17 @@ mod tests { 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] fn frame_with_contained_child_covers_shadow_descendants() { let (pool, frame_id) =