mirror of
https://github.com/penpot/penpot.git
synced 2026-09-30 07:46:16 +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:
|
||||
- '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 }}
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -59,6 +59,7 @@
|
||||
:feDiffuseLighting
|
||||
:feDisplacementMap
|
||||
:feDistantLight
|
||||
:feDropShadow
|
||||
:feFlood
|
||||
:feFuncA
|
||||
:feFuncB
|
||||
@ -439,6 +440,7 @@
|
||||
:feConvolveMatrix
|
||||
:feDiffuseLighting
|
||||
:feDisplacementMap
|
||||
:feDropShadow
|
||||
:feFlood
|
||||
:feGaussianBlur
|
||||
:feImage
|
||||
|
||||
@ -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]))))))
|
||||
|
||||
@ -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}]}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user