🐛 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:
Elena Torró 2026-09-28 07:29:25 +02:00 committed by GitHub
parent 9d08e26cb3
commit c44484a1e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 363 additions and 72 deletions

View File

@ -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 }}

View File

@ -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)))

View File

@ -59,6 +59,7 @@
:feDiffuseLighting
:feDisplacementMap
:feDistantLight
:feDropShadow
:feFlood
:feFuncA
:feFuncB
@ -439,6 +440,7 @@
:feConvolveMatrix
:feDiffuseLighting
:feDisplacementMap
:feDropShadow
:feFlood
:feGaussianBlur
:feImage

View File

@ -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]))))))

View File

@ -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}]}

View File

@ -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;

View File

@ -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<Fill> {
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

View File

@ -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))

View File

@ -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<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)));
}

View File

@ -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<Uuid> = 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<Uuid> = 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<Uuid> = 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<R: ShapeRenderer + ?Sized>(
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<R: ShapeRenderer + ?Sized>(
| 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();

View File

@ -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<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)
/// 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) =