diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72121491a..8f69c9d5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,9 @@ This changelog also contains important changes in dependencies.
- Incorrect transform order on the outermost `svg` element with a `viewBox`. (#899)
- Relative positioning of right-to-left text with `dx` and `dy`, including text on a path and text decorations. (#1093)
- `text-anchor` now uses the actual extent of a text chunk, which `dx` and `textLength` can change. (#1145)
+- Filters inside rotated or skewed groups are now applied in the user space of the group.
+ This fixes the clipping to filter regions and subregions, as well as the direction of
+ offsets, blurs, turbulence and tiles, and the rendering of `feImage`. (#949)
## [0.48.1] 2026-08-02
diff --git a/crates/resvg/src/render.rs b/crates/resvg/src/render.rs
index c879183f1..ae9a87f6a 100644
--- a/crates/resvg/src/render.rs
+++ b/crates/resvg/src/render.rs
@@ -59,6 +59,50 @@ fn render_group(
return Some(());
}
+ let rest = if group.filters().is_empty() {
+ tiny_skia::Transform::identity()
+ } else {
+ let sx = transform.sx.hypot(transform.ky);
+ let sy = transform.kx.hypot(transform.sy);
+ tiny_skia::Transform::from_row(
+ transform.sx / sx,
+ transform.ky / sx,
+ transform.kx / sy,
+ transform.sy / sy,
+ 0.0,
+ 0.0,
+ )
+ };
+ let inv_rest = rest.invert()?;
+
+ let max_bbox = ctx.max_bbox.to_rect().transform(inv_rest)?.round_out()?;
+ let (layer, ibbox) =
+ render_layer(group, &Context { max_bbox }, inv_rest.pre_concat(transform))?;
+
+ let paint = tiny_skia::Paint {
+ shader: tiny_skia::Pattern::new(
+ layer.as_ref(),
+ tiny_skia::SpreadMode::Pad,
+ tiny_skia::FilterQuality::Bilinear,
+ group.opacity().get(),
+ tiny_skia::Transform::from_translate(ibbox.x() as f32, ibbox.y() as f32),
+ ),
+ blend_mode: convert_blend_mode(group.blend_mode()),
+ anti_alias: true,
+ ..tiny_skia::Paint::default()
+ };
+ let path = tiny_skia::PathBuilder::from_rect(ibbox.to_rect());
+ pixmap.fill_path(&path, &paint, tiny_skia::FillRule::Winding, rest, None);
+
+ Some(())
+}
+
+/// Renders an isolated group into a new layer, returning it and its position.
+fn render_layer(
+ group: &usvg::Group,
+ ctx: &Context,
+ transform: tiny_skia::Transform,
+) -> Option<(tiny_skia::Pixmap, tiny_skia::IntRect)> {
let bbox = group.layer_bounding_box().transform(transform)?;
let mut ibbox = if group.filters().is_empty() {
@@ -124,22 +168,7 @@ fn render_group(
crate::mask::apply(mask, ctx, transform, &mut sub_pixmap);
}
- let paint = tiny_skia::PixmapPaint {
- opacity: group.opacity().get(),
- blend_mode: convert_blend_mode(group.blend_mode()),
- quality: tiny_skia::FilterQuality::Nearest,
- };
-
- pixmap.draw_pixmap(
- ibbox.x(),
- ibbox.y(),
- sub_pixmap.as_ref(),
- &paint,
- tiny_skia::Transform::identity(),
- None,
- );
-
- Some(())
+ Some((sub_pixmap, ibbox))
}
pub fn convert_blend_mode(mode: usvg::BlendMode) -> tiny_skia::BlendMode {
diff --git a/crates/resvg/tests/extra/filter-with-transform-on-shape.png b/crates/resvg/tests/extra/filter-with-transform-on-shape.png
index c8de330cd..219a9c482 100644
Binary files a/crates/resvg/tests/extra/filter-with-transform-on-shape.png and b/crates/resvg/tests/extra/filter-with-transform-on-shape.png differ
diff --git a/crates/resvg/tests/integration/render.rs b/crates/resvg/tests/integration/render.rs
index adb80acdc..b89f30762 100644
--- a/crates/resvg/tests/integration/render.rs
+++ b/crates/resvg/tests/integration/render.rs
@@ -348,6 +348,7 @@ use crate::render;
#[test] fn filters_filter_with_multiple_transforms_1() { assert_eq!(render("tests/filters/filter/with-multiple-transforms-1"), 0); }
#[test] fn filters_filter_with_multiple_transforms_2() { assert_eq!(render("tests/filters/filter/with-multiple-transforms-2"), 0); }
#[test] fn filters_filter_with_region_and_filterUnits_eq_userSpaceOnUse() { assert_eq!(render("tests/filters/filter/with-region-and-filterUnits=userSpaceOnUse"), 0); }
+#[test] fn filters_filter_with_region_and_skew_on_intermediate_result() { assert_eq!(render("tests/filters/filter/with-region-and-skew-on-intermediate-result"), 0); }
#[test] fn filters_filter_with_region_and_subregion() { assert_eq!(render("tests/filters/filter/with-region-and-subregion"), 0); }
#[test] fn filters_filter_with_region_outside_the_canvas() { assert_eq!(render("tests/filters/filter/with-region-outside-the-canvas"), 0); }
#[test] fn filters_filter_with_region_outside_the_viewbox() { assert_eq!(render("tests/filters/filter/with-region-outside-the-viewbox"), 0); }
@@ -355,6 +356,7 @@ use crate::render;
#[test] fn filters_filter_with_subregion_1() { assert_eq!(render("tests/filters/filter/with-subregion-1"), 0); }
#[test] fn filters_filter_with_subregion_2() { assert_eq!(render("tests/filters/filter/with-subregion-2"), 0); }
#[test] fn filters_filter_with_subregion_3() { assert_eq!(render("tests/filters/filter/with-subregion-3"), 0); }
+#[test] fn filters_filter_with_subregion_and_rotation() { assert_eq!(render("tests/filters/filter/with-subregion-and-rotation"), 0); }
#[test] fn filters_filter_with_transform_outside_of_canvas() { assert_eq!(render("tests/filters/filter/with-transform-outside-of-canvas"), 0); }
#[test] fn filters_filter_without_region_and_filterUnits_eq_userSpaceOnUse() { assert_eq!(render("tests/filters/filter/without-region-and-filterUnits=userSpaceOnUse"), 0); }
#[test] fn filters_filter_zero_sized_subregion() { assert_eq!(render("tests/filters/filter/zero-sized-subregion"), 0); }
diff --git a/crates/resvg/tests/tests/filters/feDiffuseLighting/complex-transform.png b/crates/resvg/tests/tests/filters/feDiffuseLighting/complex-transform.png
index c2d85fef7..c67a6ff44 100644
Binary files a/crates/resvg/tests/tests/filters/feDiffuseLighting/complex-transform.png and b/crates/resvg/tests/tests/filters/feDiffuseLighting/complex-transform.png differ
diff --git a/crates/resvg/tests/tests/filters/feFlood/complex-transform.png b/crates/resvg/tests/tests/filters/feFlood/complex-transform.png
index da4fe235a..1137ef330 100644
Binary files a/crates/resvg/tests/tests/filters/feFlood/complex-transform.png and b/crates/resvg/tests/tests/filters/feFlood/complex-transform.png differ
diff --git a/crates/resvg/tests/tests/filters/feGaussianBlur/complex-transform.png b/crates/resvg/tests/tests/filters/feGaussianBlur/complex-transform.png
index 35405b341..ef6ceda36 100644
Binary files a/crates/resvg/tests/tests/filters/feGaussianBlur/complex-transform.png and b/crates/resvg/tests/tests/filters/feGaussianBlur/complex-transform.png differ
diff --git a/crates/resvg/tests/tests/filters/feImage/link-on-an-element-with-complex-transform.png b/crates/resvg/tests/tests/filters/feImage/link-on-an-element-with-complex-transform.png
index dfbc0e20d..3dbe8db1d 100644
Binary files a/crates/resvg/tests/tests/filters/feImage/link-on-an-element-with-complex-transform.png and b/crates/resvg/tests/tests/filters/feImage/link-on-an-element-with-complex-transform.png differ
diff --git a/crates/resvg/tests/tests/filters/feImage/with-subregion-5.png b/crates/resvg/tests/tests/filters/feImage/with-subregion-5.png
index 419b907ee..407624697 100644
Binary files a/crates/resvg/tests/tests/filters/feImage/with-subregion-5.png and b/crates/resvg/tests/tests/filters/feImage/with-subregion-5.png differ
diff --git a/crates/resvg/tests/tests/filters/feMerge/complex-transform.png b/crates/resvg/tests/tests/filters/feMerge/complex-transform.png
index 6d31c5fa3..95d5d112c 100644
Binary files a/crates/resvg/tests/tests/filters/feMerge/complex-transform.png and b/crates/resvg/tests/tests/filters/feMerge/complex-transform.png differ
diff --git a/crates/resvg/tests/tests/filters/feOffset/complex-transform.png b/crates/resvg/tests/tests/filters/feOffset/complex-transform.png
index 043c0f81d..a6cf6d7f7 100644
Binary files a/crates/resvg/tests/tests/filters/feOffset/complex-transform.png and b/crates/resvg/tests/tests/filters/feOffset/complex-transform.png differ
diff --git a/crates/resvg/tests/tests/filters/fePointLight/complex-transform.png b/crates/resvg/tests/tests/filters/fePointLight/complex-transform.png
index 81c0075c2..1f16d9fcd 100644
Binary files a/crates/resvg/tests/tests/filters/fePointLight/complex-transform.png and b/crates/resvg/tests/tests/filters/fePointLight/complex-transform.png differ
diff --git a/crates/resvg/tests/tests/filters/feSpotLight/complex-transform.png b/crates/resvg/tests/tests/filters/feSpotLight/complex-transform.png
index ee04c567c..0f53a3361 100644
Binary files a/crates/resvg/tests/tests/filters/feSpotLight/complex-transform.png and b/crates/resvg/tests/tests/filters/feSpotLight/complex-transform.png differ
diff --git a/crates/resvg/tests/tests/filters/feTile/complex-transform.png b/crates/resvg/tests/tests/filters/feTile/complex-transform.png
index 8c4d02613..e38988b05 100644
Binary files a/crates/resvg/tests/tests/filters/feTile/complex-transform.png and b/crates/resvg/tests/tests/filters/feTile/complex-transform.png differ
diff --git a/crates/resvg/tests/tests/filters/feTurbulence/complex-transform.png b/crates/resvg/tests/tests/filters/feTurbulence/complex-transform.png
index 77a13ed0a..073df2678 100644
Binary files a/crates/resvg/tests/tests/filters/feTurbulence/complex-transform.png and b/crates/resvg/tests/tests/filters/feTurbulence/complex-transform.png differ
diff --git a/crates/resvg/tests/tests/filters/filter/transform-on-shape-with-filter-region.png b/crates/resvg/tests/tests/filters/filter/transform-on-shape-with-filter-region.png
index 7202dabc4..d13fec6de 100644
Binary files a/crates/resvg/tests/tests/filters/filter/transform-on-shape-with-filter-region.png and b/crates/resvg/tests/tests/filters/filter/transform-on-shape-with-filter-region.png differ
diff --git a/crates/resvg/tests/tests/filters/filter/transform-on-shape.png b/crates/resvg/tests/tests/filters/filter/transform-on-shape.png
index 9e80674fb..415f356f2 100644
Binary files a/crates/resvg/tests/tests/filters/filter/transform-on-shape.png and b/crates/resvg/tests/tests/filters/filter/transform-on-shape.png differ
diff --git a/crates/resvg/tests/tests/filters/filter/with-multiple-transforms-1.png b/crates/resvg/tests/tests/filters/filter/with-multiple-transforms-1.png
index 4b0e6b89c..97f291804 100644
Binary files a/crates/resvg/tests/tests/filters/filter/with-multiple-transforms-1.png and b/crates/resvg/tests/tests/filters/filter/with-multiple-transforms-1.png differ
diff --git a/crates/resvg/tests/tests/filters/filter/with-region-and-skew-on-intermediate-result.png b/crates/resvg/tests/tests/filters/filter/with-region-and-skew-on-intermediate-result.png
new file mode 100644
index 000000000..578f51a0c
Binary files /dev/null and b/crates/resvg/tests/tests/filters/filter/with-region-and-skew-on-intermediate-result.png differ
diff --git a/crates/resvg/tests/tests/filters/filter/with-region-and-skew-on-intermediate-result.svg b/crates/resvg/tests/tests/filters/filter/with-region-and-skew-on-intermediate-result.svg
new file mode 100644
index 000000000..de94a6f32
--- /dev/null
+++ b/crates/resvg/tests/tests/filters/filter/with-region-and-skew-on-intermediate-result.svg
@@ -0,0 +1,20 @@
+
diff --git a/crates/resvg/tests/tests/filters/filter/with-subregion-and-rotation.png b/crates/resvg/tests/tests/filters/filter/with-subregion-and-rotation.png
new file mode 100644
index 000000000..99c3ec664
Binary files /dev/null and b/crates/resvg/tests/tests/filters/filter/with-subregion-and-rotation.png differ
diff --git a/crates/resvg/tests/tests/filters/filter/with-subregion-and-rotation.svg b/crates/resvg/tests/tests/filters/filter/with-subregion-and-rotation.svg
new file mode 100644
index 000000000..ff98b8246
--- /dev/null
+++ b/crates/resvg/tests/tests/filters/filter/with-subregion-and-rotation.svg
@@ -0,0 +1,14 @@
+