Skip to content

Fix feOffset/feDropShadow under rotated or skewed transforms - #1081

Open
StefanoD wants to merge 2 commits into
linebender:mainfrom
StefanoD:fix-feoffset-rotated-transform
Open

StefanoD wants to merge 2 commits into
linebender:mainfrom
StefanoD:fix-feoffset-rotated-transform

Conversation

@StefanoD

Copy link
Copy Markdown
Contributor

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

Generated by Claude


Note: I accidentally closed the original PR (#1063) by deleting my fork, which auto-closed it. This reopens the same change — the branch and commits are unchanged.

@luisbg

luisbg commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

lgtm

Nit: The old code had match scale_coordinates(...) { None => return Ok(input) }, but scale_coordinates ends in Some((x * sx, y * sy)) and can never return None, the Option is dead. Worth deleting the Option from scale_coordinates too while you’re in there, or at least noting that no behaviour was lost with the early return.

This could be merged as is and I can follow up with that fix.

StefanoD and others added 2 commits September 9, 2026 17:18
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 linebender#949

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
@StefanoD
StefanoD force-pushed the fix-feoffset-rotated-transform branch from 3987b4d to 56fde4b Compare September 9, 2026 15:41
@StefanoD

StefanoD commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks! Done — the Option is gone rather than just noted.

scale_coordinates now returns (f32, f32). Its three remaining call sites, all of which had dead error handling:

site before after
apply_morphology None => return Ok(Image::from_image(pixmap, cs)) arm deleted
apply_displacement_map None => return Ok(Image::from_image(pixmap1, cs)) arm deleted
resolve_std_dev scale_coordinates(..)? ? dropped

No behaviour was lost: ts.get_scale() is total, so the None branch was unreachable in every case. resolve_std_dev keeps its own Option return — it still has live return None paths for the zero-sigma case.

While writing the doc comment for it I noticed the description I first reached for was wrong, so for the record: Transform::get_scale returns the lengths of the matrix rows, sqrt(sx^2 + kx^2) and sqrt(ky^2 + sy^2). Rotation and skew are not discarded — their magnitude is folded in, and only the direction is lost. That is visible in this PR's own worked example: the old device offset was 20 * sqrt(1.5^2 + (1.5*tan30)^2) = 34.64px, not 20 * 1.5 = 30px. The doc comment says that now.

I also rebased onto main (0.48.1 + the fit_to_rect filter-region clamp from #1021/#1007). Two notes on that:

  • Git merged CHANGELOG.md without a conflict but put the new entry at the end of the released [0.48.0] section, since main had removed the text it was anchored to. Moved back under [Unreleased].
  • The three regenerated reference PNGs still match on top of the new filter-region clamp — cargo test --all --release is green (1731 render tests), so no regeneration was needed. The blobs are byte-identical to the pre-rebase ones.

Separately, and out of scope here: apply_displacement_map passes fe.scale() * canvas_scale into displacement_map::apply, whose parameter is documented as canvas scale and which multiplies by fe.scale() again, so the displacement comes out as scale^2. That is pre-existing on main and untested (tests/filters/feDisplacementMap/simple-case.svg contains no feDisplacementMap element). Happy to file it separately.

🤖 Generated with Claude Code

@luisbg

luisbg commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Thank you Stefano

@luisbg

luisbg commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

lgtm

@DJMcNab or @nicoburns do you have time to do a final review?

@luisbg

luisbg commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Sorry for the earlier drive-by. Here's what I actually checked:

  • Read the issue (filter within <g transform="matrix(a b c d e f)"> cause inconsistent with chrome #949 ) and the fix to familiarise myself with things
  • Merges cleanly to main, no conflicting paths
  • Ran the tests before and after on the same commit. Before: "1,793 passed / 0 failed", after: "1,795 passed / 0 failed". cargo fmt --check is clean, and clippy stays at 96 warnings, the same count as main, so nothing new is introduced
  • The bug is real and still present. I applied the PR's test files alone to unmodified main, with none of its source changes, and three golden tests fail.
  • I checked the follow-up commit that dropped the dead Option from scale_coordinates, since that was my nit and I wanted to be sure nothing was lost with it. All three remaining call sites still guard correctly: apply_morphology keeps if !(rx > 0.0 && ry > 0.0), the displacement map site passes the values straight through, and the blur site still handles zero and negative values. The removed None arms were unreachable.
  • I did not verify the math against the filter spec myself. The reasoning in the commit message is consistent with the three golden test images and with the unit tests included in the PR
  • I had an LLM assist me with running the tests and checking the call sites

I think this one is ready. Fixes #949

@DJMcNab DJMcNab left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have enough context to validate this, and the gut check with the snapshot tests against the browser isn't convincing.

Separately, We haven't got a defined policy on this written down, but I'm not going to merge any PR with Claude listed as a co-author.

Comment on lines +587 to +589
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you think that "not just scaled by the transform's scale factors." is a useful comment? I also see that you've repeated it...

If you're going to use Claude, at least read it's output and remove the garbage comments...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new screenshot still doesn't match my browser.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new screenshot seems to be a regression compared to Firefox/Chrome?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does look like an improvement.

@StefanoD

Copy link
Copy Markdown
Contributor Author

@DJMcNab Sorry, I don't have time anymore to continue with the PR. You guys can take over the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

filter within <g transform="matrix(a b c d e f)"> cause inconsistent with chrome

3 participants