Skip to content

Add feTile filter support#171

Open
djberg96 wants to merge 7 commits into
weisJ:masterfrom
djberg96:fetile
Open

Add feTile filter support#171
djberg96 wants to merge 7 commits into
weisJ:masterfrom
djberg96:fetile

Conversation

@djberg96

@djberg96 djberg96 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

My continuing effort to improve filter support. :)

Add feTile filter support

Summary

This PR adds support for the SVG feTile filter primitive.

The implementation registers a real FeTile node instead of treating feTile as a dummy filter primitive. It tiles the input channel's computed filter layout region across the feTile primitive region, which matches the way feTile is meant to repeat the selected input image.

To make that possible, the filter apply phase now keeps a FilterLayoutContext alongside the image channel storage. That lets primitives which need layout information during pixel processing, like feTile, use the same layout bounds computed by the existing filter pipeline.

Tests

Added filter/tile.svg, which creates a small turbulence tile and repeats it over a larger filter primitive region with feTile.

Added a focused test in FilterTest that renders the SVG and verifies that selected pixels repeat every 20 px horizontally and vertically. The test intentionally checks the tiling behavior directly rather than comparing against Batik, because Batik and jsvg produce noticeably different feTurbulence pixels.

Local Verification

  • Direct javac compile of the changed main sources passed.
  • Direct javac compile of FilterTest passed.
  • git diff --check passed.
  • Rendered preview image: /tmp/jsvg-feTile.png
  • Manual pixel-periodicity check on the preview passed.

I could not run the Gradle test task end-to-end on this FreeBSD setup because of the same local Gradle/JDK/FreeBSD blockers as before.

AI Disclosure

Made with Codex (GPT 5.5) under my direction.

Sample

jsvg-feTile-proof

double scaleX = imageBounds.getWidth() / output.getWidth();
double scaleY = imageBounds.getHeight() / output.getHeight();

for (int y = 0; y < output.getHeight(); y++) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Looping over individual pixels feels very inefficient. Because we have access to the output image we can just call createGraphics on it and use it to paint the tiles. This way we can benefit from hardware acceleration if available.

public void applyFilter(@NotNull RenderContext context, @NotNull FilterContext filterContext) {
BufferedImage input = impl().inputChannel(filterContext).toBufferedImageNonAliased(context);
Filter.FilterInfo info = filterContext.info();
BufferedImage output = new BufferedImage(info.imageWidth, info.imageHeight, BufferedImage.TYPE_INT_ARGB);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Don‘t call the constructors by yourself, use the factories in ImageUtil instead.


@Override
public void applyFilter(@NotNull RenderContext context, @NotNull FilterContext filterContext) {
BufferedImage input = impl().inputChannel(filterContext).toBufferedImageNonAliased(context);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

input is used non destructively in the following, so toImage can be used.

for (SVGNode child : children()) {
FilterPrimitive filterPrimitive = (FilterPrimitive) child;
try {
filterPrimitive.layoutFilter(context, filterContext.layoutContext());

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Why is this needed?

@djberg96

Copy link
Copy Markdown
Contributor Author

Ok, updated the PR:

  • Reworked FeTile.applyFilter to tile via Graphics2D.drawImage instead of looping over individual pixels.
  • Switched output image creation to ImageUtil.createCompatibleTransparentImage.
  • Changed the input channel access from toBufferedImageNonAliased to toImage, since the input is only read and drawn.
  • Removed the extra layoutFilter call from Filter.applyFilter. Instead, FilterBounds now keeps the FilterLayoutContext from the normal layout pass, and FilterContext reuses that context during apply. This gives feTile access to the input primitive's layout bounds without recomputing layout while filters are being applied.

Verification:

  • Direct javac compile of the changed main sources passed.
  • Direct javac compile of FilterTest passed.
  • git diff --check passed.
  • Rendered /tmp/jsvg-feTile.png.
  • Pixel-periodicity check on the rendered image passed

GeometryUtil.adjustForAliasing(clipHeuristicBounds);

return new FilterBounds(elementBounds.boundingBox(), filterRegion, clipHeuristicBounds);
return new FilterBounds(elementBounds.boundingBox(), filterRegion, clipHeuristicBounds, filterLayoutContext);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Let’s give this a more descriptive name, now that it also holds the layout. Maybe FilterLayout.

private final @NotNull Rectangle2D elementBounds;
private final @NotNull Rectangle2D filterRegion;
private final @NotNull Rectangle2D effectiveFilterArea;
private final @NotNull FilterLayoutContext filterLayoutContext;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I don’t like storing the mutable FilterLayoutContext here. Maybe refactor to only store the ChannelStorage but with just a non-mutable interface type. This makes it so no accidental changes to the layout can happen after the layout has already been computed. Hope this makes sense, if needed I can give a more detailed description of what I have in mind.

@djberg96

Copy link
Copy Markdown
Contributor Author

Ok, here's what Codex came up with:

Changes made:

  • Renamed the bounds carrier from FilterBounds to FilterLayout, since it now represents the computed filter layout rather than only geometric bounds.
  • Renamed createFilterBounds to createFilterLayout to match the new type name.
  • Added a small read-only ChannelProvider<T> interface with only get(...).
  • Made ChannelStorage<T> implement ChannelProvider<T>, while keeping mutation methods only on ChannelStorage.
  • Changed FilterLayout to retain only ChannelProvider<LayoutBounds> instead of the mutable FilterLayoutContext.
  • Updated FilterContext to expose the read-only layout channels through layoutChannels().
  • Updated feTile to read the input tile bounds from those read-only layout channels and compute its own primitive region directly from primitiveUnits and elementBounds.

Verification:

  • git diff --check passed.
  • Direct javac compile of the touched main classes passed.

@weisJ

weisJ commented Jul 14, 2026

Copy link
Copy Markdown
Owner

This svg is rendered incorrectly:

<svg
        width="200"
        height="200"
        xmlns="http://www.w3.org/2000/svg"
        xmlns:xlink="http://www.w3.org/1999/xlink">
    <title>
        Tiling an MDN logo with the Mozilla mascot's head that is on the logo
    </title>
    <defs>
        <!-- Define the region of the filter to be the bounding box of the
             MDN logo being filtered. These parameters will create an output
             that covers the same region as the image. -->
        <filter id="tile" x="0" y="0" width="100%" height="100%">
            <!-- Create a tile from the central portion of the image from
                 (50,50) to (150,150). This area is essentially the Mozilla
                 mascot's head. -->
            <feTile in="SourceGraphic" x="50" y="50" width="100" height="100" />

            <!-- Without specifying a region, feTile defaults to the region
                 of the filter. Without specifying an "in" parameter, the default
                 is the result of the previous primitive. So this second feTile
                 will tile the entire filter region with the mascot's head. -->
            <feTile />
        </filter>
    </defs>

    <!-- Use the MDN logo as input to the filter -->
    <image
            href="data:image/png;base64,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"
            x="10%"
            y="10%"
            width="80%"
            height="80%"
            filter="url(#tile)" />
</svg>

Expected:
tmp
Actual:
image

@djberg96

Copy link
Copy Markdown
Contributor Author

Oops, sorry about that. That AI agent has been flogged.

What was wrong:

  • The earlier local render check was accidentally using stale compiled classes, so feTile was still effectively registered as a dummy in that harness. That missed the visual bug.
  • For chained feTile, apply-time layout lookup was using the final layout channel state instead of the layout channel state from just before each primitive ran.
  • feTile also needs to use the input bounds clipped to the current primitive region as the source tile. Without that, the first feTile can preserve too much of the source image instead of extracting the requested tile region.

Changes made:

  • ChannelStorage can now produce a read-only snapshot.
  • FilterLayout stores read-only layout-channel snapshots per filter primitive.
  • Filter.applyFilter sets the correct per-primitive layout snapshot before applying each primitive.
  • FeTile intersects the input layout bounds with its own primitive region before converting that tile source to pixels.
  • Added a chained feTile regression resource and pixel assertions in FilterTest.

Verification:

  • Rendered your MDN repro through the current JSVG code and compared it visually with an independent rsvg-convert render.
  • Rendered a smaller synthetic quadrant repro through both JSVG and rsvg-convert; the outputs now match visually.
  • Checked exact pixels for the new chained-tile regression image.
  • Compiled the full filter package plus NodeSupplier, renderer callers, and FilterTest.
  • Ran testTile() and testChainedTileUsesPrimitiveRegion() through a direct same-package runner.
  • git diff --check passed.

I also checked it visually this time.

@weisJ

weisJ commented Jul 14, 2026

Copy link
Copy Markdown
Owner
  • ChannelStorage can now produce a read-only snapshot.
  • FilterLayout stores read-only layout-channel snapshots per filter primitive.

This is very wasteful, given that a given filter primitive should only care about its input and is the only one modifying its own output. One should be able to extract the computed bound per channel at the time the filter layout is calculated, i.e. at the time resolve is called on the final output.

@djberg96

Copy link
Copy Markdown
Contributor Author

Ok, I had Codex address that specific complaint, then I had it do another pass over the feTile implementation in general with both efficiency and correctness concerns in mind.

Changes made:

  • Removed the duplicated primitive-region calculation from FeTile.applyFilter.
  • FilterLayout now records the resolved input and output layout bounds for each primitive during layout.
  • FeTile now uses the already-resolved output layout bounds as its primitive region, and intersects that with the resolved input bounds to get the tile source area.
  • Simplified FeMerge.layoutFilter() to reuse its inputLayout() computation instead of duplicating the same union logic.
  • Fixed <image> element bounds to match normal rendering behavior when the image resource is missing, by using MissingImageResource instead of falling back to zero intrinsic size.
  • Added a regression SVG/test for feTile applied to an <image>, ensuring the tiled output stays within the image/filter bounds and does not leak into the surrounding viewport.

Verification:

  • Compiled the full jsvg main source tree locally into a clean /tmp class output.
  • Ran the focused feTile tests through a local runner:
    • testTile
    • testChainedTileUsesPrimitiveRegion
    • testTileUsesImageBounds
  • Generated a visual proof image for the image-bounds case and sampled it:
    • cornerAlpha=0
    • centerAlpha=255
    • centerRGB=FFFF0000
  • Ran git diff --check.

If this doesn't work then I think I'm going to wave the white flag and let you either take it from here, or just close the PR. If that's the case, then feel free to cherry pick any bits that were useful, if any.

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.

2 participants