rototor / pdfbox-graphics2d

Graphics2D Bridge for pdfbox
59 stars 22 forks source link

Discrepancy between PDF & PNG when translating an SVG that contains a linear gradient #19

Closed larrylynn-wf closed 4 years ago

larrylynn-wf commented 4 years ago

Greetings Rototor.

First, I wanted to write and say thank you for your work on this awesome library. We're getting great results using it to embed SVGs in PDFs.

We did notice something that looked a bit weird when we had SVGs representing a chart that had a linear background gradient. When the SVG contains a linear gradient, a gradient vector image is embedded in the PDF that has a different orientation than the original SVG as rendered in a browser. See attached screenshot. 2019-09-18_1027

When I first noticed the visual discrepancy between SVG input & PDF output, I thought that it might be a bug in pdfbox-graphics2d. After reading up on the SVG and PDF specifications, I no longer think it's a bug.

The SVG specification https://www.w3.org/TR/SVG11/pservers.html#LinearGradients says that

When the object's bounding box is not square, the gradient normal which is initially perpendicular to the gradient vector within object bounding box space may render non-perpendicular relative to the gradient vector in user space.

However, the PDF specification states, in the section for "Type 2 (Axial) Shadings" https://www.adobe.com/content/dam/acom/en/devnet/pdf/pdfs/pdf_reference_archives/PDFReference.pdf

Type 2 (axial) shadings define a color blend that varies along a linear axis between two endpoints and extends indefinitely perpendicular to that axis.

So, I think that the root issue is a mismatch between the 2 specifications. In PDF, the gradient normal is always perpendicular to the gradient vector. In SVG, the gradient normal is sometimes perpendicular. Therefore, translating the SVG to a PDF gradient that has the gradient normal going perpendicular is technically correct according to my reading of the specification.

However, since pdfbox-graphics2d is intended as a bridge between SVGs and PDFBox, I think it's a reasonable feature request to ask for an option that allows us to make linear gradients look the same in a PDF as they do when the input SVG is rendered as a browser.

I've opened a pull request that can be used to demonstrate this issue: https://github.com/rototor/pdfbox-graphics2d/pull/18

2019-09-18_1045 Running mvn test will translate that SVG to both a PDF and a PNG. Note that the orientation of the gradient in the PDF does not match the orientation of the gradient in the PNG. See attached screenshot.

rototor commented 4 years ago

Thanks for the detailed report and the test case. I'll try to look into, but I will likely have no time for that this week.

rototor commented 4 years ago

Oh, I should not write fix in the commit message, as it would close the issue ...

I'm nearly there: image

The start and end points need to be moved future away from the center. I tried different things, but that did not work. I had to disable this for now as it breaks other gradient SVG test cases. I assume as soon as the scaling is right the other test cases would be ok with that code. So this is WIP at the moment.

If you can spare some time could you try your luck here? It's in PdfBoxGraphics2DPaintApplier.java:374 and currently disabled:

            /*
             * Special handling for Batik
             */
            if (!isNormalParallel && false)
larrylynn-wf commented 4 years ago

Thank you Emmeran. I will work on this today.

larrylynn-wf commented 4 years ago

Hi Emmeran. Thanks again for your work on this issue.

I've pulled down your new code & re-enabled your updated logic by changing 'false' to 'true' on PdfBoxGraphics2DPaintApplier.java:374. I'm able to reproduce your results. When I translate long-gradient.svg to an SVG using your test harness, the PDF looks very close to the SVG as rendered in a web browser.

Unfortunately, I don't believe that you've found the general solution to this issue. I processed some other SVGs using your updated code and the translation to PDF looks quite a bit different. I've checked in a new SVG named 'tall-gradient.svg' and pushed it up to a branch in order to demonstrate this https://github.com/rototor/pdfbox-graphics2d/compare/master...larrylynn-wf:gradient-issue-2?expand=1

Here's a screenshot of my results 2019-09-23_1553 It looks to me like the normal of the gradient (the white band) has been pushed too far to the right.

rototor commented 4 years ago

TBH I'm not surprised. I don't really understand what

may render non-perpendicular relative to the gradient vector in user space

should mean. Especially that "may render" seems like "sometimes", which makes not that much sense to me... I did only try&error, as I not really understand yet whats wrong here.

I just found the draft SVG 2 spec, which may clear up that thing a bit. https://svgwg.org/svg2-draft/pservers.html#LinearGradientElement

larrylynn-wf commented 4 years ago

Hi Emmeran. I made an attempt to implement the feature requested in Issue 19. My PR is here: https://github.com/rototor/pdfbox-graphics2d/pull/20

I found this stack overflow post to be useful in clarifying the situation https://stackoverflow.com/questions/50617275/svg-linear-gradients-objectboundingbox-vs-userspaceonuse So, it looks like there are 2 modes for rendering linear gradients in SVGs: objectBoundingBox & userSpaceOnUse. I believe that the code in the master branch of pdfbox-graphics2d renders SVG gradients properly if they are using the userSpaceOnUse mode. My code is an attempt to add support for SVG gradients in the objectBoundingBox mode. I think objectBoundingBox mode is the default for SVG linear gradients, so hopefully this is a useful addition to the library.

The basic approach I've used is to start with a special case where SVG linear gradient default layout matches PDF axial gradient layout. The special case is a perfect square. It doesn't make sense to have a gradient over a rectangle with a zero or negative width or height. So for my base case, I use a 1x1 square. Then I use the affine transform that we already have on the state object to warp the space of the box, scaling it up to a rectangle of arbitrary size. Warping the space after applying the gradient results in a painted rectangle that looks like the SVG gradient in objectBoundingBox mode rendered in a browser.

I've isolated my code in a subroutine named linearGradientObjectBoundingBoxShading. I'm switching on a new boolean flag on PdfBoxGraphics2DPaintApplier which is named emulateObjectBoundingBox. The default of that flag is false, so without any configuration, the behavior of buildLinearGradientShading() is unchanged from that of master. My gradient layout mode can be enabled with

PdfBoxGraphics2DPaintApplier paintApplier = new PdfBoxGraphics2DPaintApplier()
paintApplier.setLinearGraidientEmulateObjectBoundingBox(true);
pdfBoxGraphics2D.setPaintApplier(paintApplier);

Where pdfBoxGraphics2D is an instance of the PdfBoxGraphics2D class.

You can check the output of my code by running mvn test and looking at the output files corresponding the the 4 new SVGs that I've added.

I expect that you'd probably want to refactor my code because the coding style is not in harmony with yours (especially in the way that I bolted on extra functionality to the existing test harness). But I think that the approach is sound, and I hope this code is useful at least as an example.

Please note that my code is not a general purpose solution to the linear gradient layout problem. I tried running my code against the rest of the test docs in ./src/test/resources/de/rototor/pdfbox/graphics2d/. Most of the SVGs look good after translation, but not all. Specifically, compuserver_msn_Ford_Focus.svg has serious regressions when translated with my code. I think that this is because that SVG has a number of linear gradients with a mix of both objectBoundingBox & userSpaceOnUse modes. I do not expect my code to handle the userSpaceOnUse mode properly.

rototor commented 4 years ago

I've removed the EmulateObjectBoundingBox boolean and instead used the scaling factors of the transform matrix to detect object bounding box. This seems to work correctly, and also does not break the more complex SVGs in the test. Please verify that it works for all your cases, then I will release a new version.

Thanks for your help with this issue!

larrylynn-wf commented 4 years ago

I will test this today. Thanks again for your work on this issue.

larrylynn-wf commented 4 years ago

Hi Emmeran, I've tested out the new code in master with some help from one of my colleagues.

My colleague found a use case that I missed that introduces a regression. The problem is in the code that I submitted in https://github.com/rototor/pdfbox-graphics2d/pull/20. If an SVG had a gradient that was exactly vertical or horizontal in orientation, I created a rectangle of height or width of zero, which was later used as a clip path. This had undesirable results.

I've created a pull request with an SVG that demonstrates this problem as well as a bugfix to resolve it: https://github.com/rototor/pdfbox-graphics2d/pull/21

I tested again with the codebase that included https://github.com/rototor/pdfbox-graphics2d/pull/21. The output PDFs looked much improved. All of the PDFs produced by testGradientSVGEmulateObjectBoundingBox() looked perfect as far as I could tell. I did find one other regression in the test for displayWebStats.svg.

Testing on master plus PR 21, the buttons in the interactive web statistics dashboard display as rectangles. Testing on the latest tagged release, graphics2d-0.24, those buttons are displayed with rounded corners. There is a similar problem with the grippies down in the bottom of the SVG. See attached screenshot.

2019-10-09_1718

I'm not quite sure what is going on there, but I suspect that it has something to do with the r attributes in the rectangle

<rect id="grip" x="-6" y="-12" width="12" height="24" rx="8" ry="8" fill="inherit" stroke="none"/>

I suspect that the clipping box that gets injected for gradients interferes with the rounding defined in those attributes. I don't know how to resolve that yet.

rototor commented 4 years ago

The problem here is, that the state.contentStream.addRect(...) you added in the object bounding box case should set a clipping path. This does not always work as expected, as you not explicit clip but rely on the fact the the graphics adapter does a clip anyway. But it does not do that in all cases.

But also just calling state.contentStream.clip() after the addRect() does not work, because that can later lead to an empty clipping path. I.e., in some case clip() is called again after that. macOS preview etc. don't care about that, but Acrobat Reader really does not like a clip() without a path and just aborts rendering.

So to correctly fix that there is a new boolean flag in the Graphics2D adapter which tracks if we currently have a path on the content stream which has not been closed. And when we try to clip it first checks if there is a path and only clips in that case.

Now that dashboard case also works for me. Please test again, thanks.

larrylynn-wf commented 4 years ago

Hi Emeran, Thank you for your recent code updates.

As it happens I also thought about adding boolean flag in the Graphics2D adapter to help manage clipping. But I thought that you might be more amenable to accepting a code contribution from a new contributor if I could isolate all of my changes to a single method. I guess I should have explored all options.

I've tested the most recent code in master. I can find no regressions in the translation of any of the SVGs in ./src/test/resources/de/rototor/pdfbox/graphics2d/. SVGs with linear gradients in both the objectBoundingBox and the userSpaceOnUse modes now seem to translate properly. Rectangles with rounded corners are not adversely effected. I also did some exploratory testing translating SVGs generated by our own internal systems. The exploratory testing passed. All of the test cases I can fabricate look great when translated with the new codebase.

We would be interested in using this code as soon as a new version of pdfbox-graphics2d is released.

rototor commented 4 years ago

@larrylynn-wf Nice that it works for you. I don't mind any contribution as long as it's sound.

I've tried to released version 0.25, but sonatype currently has problems (see https://status.maven.org/incidents/t40ylgmsmbl2?u=2thbn6r7vdfk), so I'll retry later. I'll give you an update if publishing works again and the releasing worked.

rototor commented 4 years ago

I could finally publish version 0.25, so you can use that now. Thanks again for the report and help with this issue.

larrylynn-wf commented 4 years ago

We have a preliminary build of our software integrated with pdfbox-graphics2d version 0.25. The results look great so far.

Thanks again for your work on this issue, and for my part, I was happy to be of assistance.