From 1bb8379602dfc5b0bbb32eb4c6bb7549f6c3488e Mon Sep 17 00:00:00 2001 From: Stefano Di Martino Date: Sun, 14 Jun 2026 09:31:24 +0200 Subject: [PATCH 1/2] Fix feOffset/feDropShadow under rotated or skewed transforms feOffset's dx/dy were mapped through `scale_coordinates`, which only multiplies by the transform's scale factors and discards rotation and skew. As a result, an offset inside a rotated or skewed group was applied axis-aligned in device space instead of being rotated together with the filtered content, producing output inconsistent with browsers. Unlike blur radii, an offset is a vector and can be represented exactly, so map dx/dy through the full linear part of the transform via a new `transform_coordinates` helper, used by both feOffset and feDropShadow. Adds unit tests for the coordinate mapping and regenerates the feOffset/feMerge/feTile complex-transform references, which previously encoded the buggy behavior. Regenerated references show a shift: in these tests the filter output is the offset result itself (e.g. feOffset's complex-transform has only an feOffset), so correcting the offset moves the whole shape. For feOffset's complex-transform (dx=20 dy=40, transform skewX(30) translate(-50), plus 1.5x viewport scale), skewX adds tan(30)*dy to the x offset: the device offset goes from ~(34.6, 60)px to ~(64.6, 60)px, i.e. ~30px further right, y unchanged. This matches the SVG spec and Chrome. Known limitation (pre-existing, not introduced here): the filter region is still clipped using the axis-aligned bounding box of the (sheared or rotated) region rather than the exact transformed rectangle. With this fix the offset is correctly sheared further out, so it can now reach the default filter region boundary and get clipped there. The clip edge is therefore axis-aligned in device space instead of following the skew as in Chrome. Because the AABB is larger than the true region, resvg never clips more than a spec-perfect renderer would. Making the region clip exact would require reworking filter-region handling and is out of scope for this fix. Fixes #949 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++ crates/resvg/src/filter/mod.rs | 70 ++++++++++++++++-- .../filters/feMerge/complex-transform.png | Bin 9062 -> 8512 bytes .../filters/feOffset/complex-transform.png | Bin 923 -> 916 bytes .../filters/feTile/complex-transform.png | Bin 453 -> 458 bytes 5 files changed, 68 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f95ecac..bcfd484fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ This changelog also contains important changes in dependencies. ## [Unreleased] +### Fixed + +- `feOffset` and `feDropShadow` now rotate and skew their `dx`/`dy` together with + the filtered content instead of only scaling it, fixing incorrect filter output + inside rotated or skewed groups. (#949) + ## [0.48.1] 2026-08-02 This release has an MSRV of 1.85.0 for `usvg` and `resvg` and the C API. diff --git a/crates/resvg/src/filter/mod.rs b/crates/resvg/src/filter/mod.rs index 30f8680c5..0af9291a3 100644 --- a/crates/resvg/src/filter/mod.rs +++ b/crates/resvg/src/filter/mod.rs @@ -584,10 +584,10 @@ fn apply_drop_shadow( ts: usvg::Transform, input: Image, ) -> Result { - let (dx, dy) = match scale_coordinates(fe.dx(), fe.dy(), ts) { - Some(v) => v, - None => return Ok(input), - }; + // The offset is a vector in user space, so it must be mapped through the + // full linear part of the transform (including rotation and skew), not just + // scaled by the transform's scale factors. + let (dx, dy) = transform_coordinates(fe.dx(), fe.dy(), ts); let mut pixmap = tiny_skia::Pixmap::try_create(input.width(), input.height())?; let input_pixmap = input.into_color_space(cs)?.take()?; @@ -670,10 +670,10 @@ fn apply_offset( ts: usvg::Transform, input: Image, ) -> Result { - let (dx, dy) = match scale_coordinates(fe.dx(), fe.dy(), ts) { - Some(v) => v, - None => return Ok(input), - }; + // The offset is a vector in user space, so it must be mapped through the + // full linear part of the transform (including rotation and skew), not just + // scaled by the transform's scale factors. See issue #949. + let (dx, dy) = transform_coordinates(fe.dx(), fe.dy(), ts); if dx.approx_zero_ulps(4) && dy.approx_zero_ulps(4) { return Ok(input); @@ -1145,3 +1145,57 @@ fn scale_coordinates(x: f32, y: f32, ts: usvg::Transform) -> Option<(f32, f32)> let (sx, sy) = ts.get_scale(); Some((x * sx, y * sy)) } + +/// Maps a coordinate *vector* (e.g. `feOffset`'s `dx`/`dy`) through the linear +/// part of the current transform. +/// +/// Unlike [`scale_coordinates`], this preserves the direction of the vector +/// under rotation and skew. This is required for offsets, which represent a +/// displacement in user space and must be rotated together with the content +/// they are applied to. +fn transform_coordinates(x: f32, y: f32, ts: usvg::Transform) -> (f32, f32) { + // Drop the translation part: we are mapping a vector, not a position. + let linear = tiny_skia::Transform::from_row(ts.sx, ts.ky, ts.kx, ts.sy, 0.0, 0.0); + let mut point = tiny_skia::Point::from_xy(x, y); + linear.map_point(&mut point); + (point.x, point.y) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx_eq(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 + } + + // Regression test for https://github.com/linebender/resvg/issues/949 + // + // An `feOffset` placed inside a rotated group must have its `dx`/`dy` + // rotated together with the content. The previous implementation only + // scaled the offset by the transform's scale factors, which dropped the + // rotation and produced an axis-aligned offset instead of a rotated one. + #[test] + fn offset_coordinates_follow_rotation() { + // A pure 90° rotation. An offset of (10, 0) must become (0, 10). + let ts = usvg::Transform::from_rotate(90.0); + let (dx, dy) = transform_coordinates(10.0, 0.0, ts); + assert!(approx_eq(dx, 0.0), "dx = {dx}"); + assert!(approx_eq(dy, 10.0), "dy = {dy}"); + + // The old, scale-only behaviour would have returned (10, 0), since the + // scale factors of a pure rotation are both 1. + let (sx, sy) = scale_coordinates(10.0, 0.0, ts).unwrap(); + assert!(approx_eq(sx, 10.0) && approx_eq(sy, 0.0)); + } + + #[test] + fn offset_coordinates_combine_rotation_and_scale() { + // 45° rotation combined with a 2x uniform scale. + let ts = usvg::Transform::from_rotate(45.0).post_scale(2.0, 2.0); + let (dx, dy) = transform_coordinates(10.0, 0.0, ts); + let expected = 10.0 * 2.0 * std::f32::consts::FRAC_1_SQRT_2; + assert!(approx_eq(dx, expected), "dx = {dx}"); + assert!(approx_eq(dy, expected), "dy = {dy}"); + } +} diff --git a/crates/resvg/tests/tests/filters/feMerge/complex-transform.png b/crates/resvg/tests/tests/filters/feMerge/complex-transform.png index 6d31c5fa3e380c69d07da0dd8f1e9d3a1e9949b9..b103cc7704621268f1d36585122a0a0df7c14099 100644 GIT binary patch literal 8512 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 VH24hOC;l@6psA*(TCQvp{y!+l+LHhP literal 9062 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 literal 923 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4rZW;-{LxVAQcke6XFV_4f+$nWQ0L~6o}Cs z0U~=M4SK0lEpx0p`Ee~S$X z2ZbVlL=&qwhyV&Uf@L&_s2~HSy5o_D;`Q msoZ{JncKF~J7sQt_Zc|e{*=!W+2RFE=nS5&elF{r5}E+NrDMYY diff --git a/crates/resvg/tests/tests/filters/feTile/complex-transform.png b/crates/resvg/tests/tests/filters/feTile/complex-transform.png index 8c4d0261332de21270140cd1ef7a9ccecf25d8f1..0db2429c9bda65e718ce27706a5ec1e4e3f847a6 100644 GIT binary patch literal 458 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&7G|JGckpC4Af+DQ6XFV_@dLf?aG-*M2<>bj z#a$BQ7wo_wV4q(wVgG^m9~L;(-vb)!?&;zfQgQ3e^~R-#9b}GwtgdkQz$7R)CGSE> zv)%_**%yV@T8x*K6% zTKG2+8+beCDv#vr$&qi1PhG3^N$)M)cI$mQNa0b7{PX`M>^~SKCq7O2>5u>t+u0cb z0%~kxKrpSdu@wkXlYTTGICSWck>Lg~n3JUg1j2`Xj{pT>qIXbj z#a$BQ7tA1F?~q?GVgG^mA1dc)P6rz6sFO`F;9*IW;8k_fpBe4a$goKKg(z19!?U8df^1f0ul{l{%1Qjzz5KSV6&YNe z1LbX%YrTDW&D+|kZ!4cgJ`dW)%gxRG`hTS0@6afJhH*Ya9v1!F!5YxcoDr`U@km!*EhYn4W_!}7k0r#ST^f^rwNvM?2Vn(2~ zr!9{j0)t5sKye{vpb!vvCnY3;z}~)1wx8B~)w(k;>FRn*h#VUET3LOmBYQ~0IV)q1 jgNbYhT6) Date: Wed, 9 Sep 2026 17:38:34 +0200 Subject: [PATCH 2/2] Drop the dead `Option` from `scale_coordinates` `scale_coordinates` ended in `Some((x * sx, y * sy))` and could never return `None`, so the `None => return Ok(...)` arms in `apply_morphology` and `apply_displacement_map`, and the `?` in `resolve_std_dev`, were all unreachable. Return `(f32, f32)` directly and drop them; no behaviour is lost. Also documents what the function actually does. `Transform::get_scale` returns the lengths of the matrix rows, `sqrt(sx^2 + kx^2)` and `sqrt(ky^2 + sy^2)`, so rotation and skew still contribute their magnitude and only the direction is discarded. That is what makes it right for a radius and wrong for the offset vector that `transform_coordinates` now handles. Co-Authored-By: Claude Opus 5 --- crates/resvg/src/filter/mod.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/resvg/src/filter/mod.rs b/crates/resvg/src/filter/mod.rs index 0af9291a3..900cd2387 100644 --- a/crates/resvg/src/filter/mod.rs +++ b/crates/resvg/src/filter/mod.rs @@ -940,10 +940,7 @@ fn apply_morphology( ) -> Result { let mut pixmap = input.into_color_space(cs)?.take()?; - let (rx, ry) = match scale_coordinates(fe.radius_x().get(), fe.radius_y().get(), ts) { - Some(v) => v, - None => return Ok(Image::from_image(pixmap, cs)), - }; + let (rx, ry) = scale_coordinates(fe.radius_x().get(), fe.radius_y().get(), ts); if !(rx > 0.0 && ry > 0.0) { pixmap.clear(); @@ -968,10 +965,7 @@ fn apply_displacement_map( let mut pixmap = tiny_skia::Pixmap::try_create(region.width(), region.height())?; - let (sx, sy) = match scale_coordinates(fe.scale(), fe.scale(), ts) { - Some(v) => v, - None => return Ok(Image::from_image(pixmap1, cs)), - }; + let (sx, sy) = scale_coordinates(fe.scale(), fe.scale(), ts); displacement_map::apply( fe, @@ -1117,7 +1111,7 @@ fn apply_to_canvas(input: Image, pixmap: &mut tiny_skia::Pixmap) -> Result<(), E /// /// If the last flag is set, then a box blur should be used. Or IIR otherwise. fn resolve_std_dev(std_dx: f32, std_dy: f32, ts: usvg::Transform) -> Option<(f64, f64, bool)> { - let (mut std_dx, mut std_dy) = scale_coordinates(std_dx, std_dy, ts)?; + let (mut std_dx, mut std_dy) = scale_coordinates(std_dx, std_dy, ts); // 'A negative value or a value of zero disables the effect of the given filter primitive // (i.e., the result is the filter input image).' @@ -1141,9 +1135,15 @@ fn resolve_std_dev(std_dx: f32, std_dy: f32, ts: usvg::Transform) -> Option<(f64 Some((std_dx as f64, std_dy as f64, box_blur)) } -fn scale_coordinates(x: f32, y: f32, ts: usvg::Transform) -> Option<(f32, f32)> { +/// Scales a magnitude *pair* (e.g. a blur or morphology radius) by the per-axis +/// scale factors of the current transform, i.e. the lengths of its matrix rows. +/// +/// Rotation and skew still contribute their magnitude here; what is lost is the +/// direction. That is right for a radius and wrong for a vector, which is what +/// [`transform_coordinates`] is for. +fn scale_coordinates(x: f32, y: f32, ts: usvg::Transform) -> (f32, f32) { let (sx, sy) = ts.get_scale(); - Some((x * sx, y * sy)) + (x * sx, y * sy) } /// Maps a coordinate *vector* (e.g. `feOffset`'s `dx`/`dy`) through the linear @@ -1185,7 +1185,7 @@ mod tests { // The old, scale-only behaviour would have returned (10, 0), since the // scale factors of a pure rotation are both 1. - let (sx, sy) = scale_coordinates(10.0, 0.0, ts).unwrap(); + let (sx, sy) = scale_coordinates(10.0, 0.0, ts); assert!(approx_eq(sx, 10.0) && approx_eq(sy, 0.0)); }