weisJ/jsvg

Filter on the root `svg` element doesn't get applied

Closed this issue · 9 comments

root-filter.svg:

<svg xmlns="http://www.w3.org/2000/svg"
    width="100" height="100" viewBox="0 0 100 100"
    filter="url(#drop-shadow)">
  <circle cx="50" cy="50" r="40" fill="cornflowerBlue" />
  <defs>
    <filter id="drop-shadow">
      <feGaussianBlur in="SourceAlpha" stdDeviation="2" />
      <feOffset dx="4" dy="4" result="offsetblur" />
      <feFlood flood-color="black" flood-opacity="0.5" />
      <feComposite in2="offsetblur" operator="in" />
      <feMerge>
        <feMergeNode />
        <feMergeNode in="SourceGraphic" />
      </feMerge>
    </filter>
  </defs>
</svg>
import java.io.File;

import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Dimension2D;
import java.awt.image.BufferedImage;

import javax.imageio.ImageIO;

import com.github.weisj.jsvg.SVGDocument;
import com.github.weisj.jsvg.parser.SVGLoader;

public class RootFilterTest {

    public static void main(String[] args) throws Exception {
        String inputName = "root-filter";
        SVGDocument svg = new SVGLoader()
                .load(new File(inputName + ".svg").toURI().toURL());
        Dimension2D size = svg.size();
        BufferedImage image = new BufferedImage((int) size.getWidth(),
                                                (int) size.getHeight(),
                                                BufferedImage.TYPE_INT_RGB);
        Graphics2D g = image.createGraphics();
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                           RenderingHints.VALUE_ANTIALIAS_ON);
        g.setColor(Color.WHITE);
        g.fillRect(0, 0, image.getWidth(), image.getHeight());
        svg.render(null, g);
        g.dispose();
        ImageIO.write(image, "png", new File(inputName + ".png"));
        System.out.println("Done.");
    }

}
Actual Expected
no-shadow with-shadow

Applying the filter on an all-encompassing g container works as expected:

<svg xmlns="http://www.w3.org/2000/svg"
    width="100" height="100" viewBox="0 0 100 100">
<g filter="url(#drop-shadow)">
  <circle cx="50" cy="50" r="40" fill="cornflowerBlue" />
</g>
  <defs>
    <filter id="drop-shadow">
      <feGaussianBlur in="SourceAlpha" stdDeviation="2" />
      <feOffset dx="4" dy="4" result="offsetblur" />
      <feFlood flood-color="black" flood-opacity="0.5" />
      <feComposite in2="offsetblur" operator="in" />
      <feMerge>
        <feMergeNode />
        <feMergeNode in="SourceGraphic" />
      </feMerge>
    </filter>
  </defs>
</svg>

Resolved in 132bf20

Tested with current 1.5.0-SNAPSHOT (1.5.0-20240226.002658) – now works as expected. Many thanks.

As a fun fact, I've just noticed Firefox (compared to Chrome, for example) doesn't appear to get this right, also.

This could be more tricky than it seems. I'm not sure which one is correct, but just want to save it for future reference. If I reduce the output size from the full view-box size:

<svg xmlns="http://www.w3.org/2000/svg"
    width="50" height="50" viewBox="0 0 100 100"
    filter="url(#drop-shadow)">

Chrome appears to apply different transformations to the filter itself compared to JSVG (zoom in the shadow blur and offset):

Chrome JSVG
Full size chrome-full-size jsvg-full-size
Half size chrome-half-size image

Chrome's result surprised me, though it could be by specification. It could happen to be some unspecified behavior, also.

If the filter is on an immediate child, Chrome and JSVG render the same:

<svg xmlns="http://www.w3.org/2000/svg"
    width="50" height="50" viewBox="0 0 100 100">
  <g filter="url(#drop-shadow)">
    ...
  </g>

On first glance it looks like Chrome's behavior is wrong here. The specification for filters defines

primitiveUnits = "userSpaceOnUse | objectBoundingBox"

Specifies the coordinate system for the various length values within the filter primitives and for the attributes that define the filter primitive subregion.

If primitiveUnits is equal to userSpaceOnUse, ...

If primitiveUnits ...

The initial value for primitiveUnits is userSpaceOnUse.

filterUnits = "userSpaceOnUse | objectBoundingBox"

Defines the coordinate system for attributes x, y, width, height.

If filterUnits is equal to userSpaceOnUse, ....

If filterUnits is equal to objectBoundingBox, ...

The initial value for filterUnits is objectBoundingBox.

See https://drafts.fxtf.org/filter-effects/#FilterElement

Either the coordinate system when the filter is applied (which is the same for top level <svg> and an intermediate <g>).

Or the coordinate system used for filters is given by the bounding box of the object it is applied to.
The bounding box of an <svg> element is computed the same ways that for e.g <g>. See https://svgwg.org/svg2-draft/coords.html#TermObjectBoundingBox (specifically the algorithm to compute the bounding box. <svg> falls under a container element.)

To me this indicates the result should look the same as if the filter were defined on an intermediate <g> node.

Just tested myself on Chrome 122.0.6261.94 and it appears that it behaves as jsvg. Which version of Chrome are you using?

I'm using Edge 122.0.2365.59 to preview the files – there I observe the problem, and then the bitmaps I've shown are generated by a build using Puppeteer (21.6.1, if I'm not mistaken) with a Chrome (could be Chromium) backend, the version of which I'm not aware of.

I decided that that the behaviour of Jsvg is the correct one in this scenario. If browser implementations don't even agree on how to handle it its probably better to steer away from making use of it anyway if one is concerned with cross-implementation compatibility.

Yep. I've settled on applying the filter on a <svg> child for the time being.