jfree / jfreesvg

A fast, lightweight Java library for creating Scalable Vector Graphics (SVG) output.
http://www.jfree.org/jfreesvg
GNU General Public License v3.0
319 stars 58 forks source link

SVGGraphics2D: drawRect and fillRect misaligned compared to Graphics2D #24

Open markustw opened 4 years ago

markustw commented 4 years ago

In Graphics2D drawing a filled rectangle with a 1-pixel border shows a correctly filled rectangle, applying the same code with SVGGraphics2D results in a misalignment of the border and the filled rectangle:

SVGGraphics2D graphics = new SVGGraphics2D(40, 40); graphics.drawRect(10, 10, 20, 10); // 1- pixel border graphics.setColor(Color.BLUE); graphics.fillRect(11, 11, 19, 9);

The method drawRect of Graphics2D internally draws four lines to get a rectangle, it seems in SVG the x,y starting-coordinate of lines is not the top-left corner but the middle of the lines which causes the misaligment. Unfortunately the class SVGGraphics2D is final so no easy workaround can be implemented.

jfree commented 4 years ago

Thanks for the code, I can reproduce this:

package org.jfree.svg;

import java.awt.Color; import java.io.File; import java.io.IOException;

public class Test { public static void main(String[] args) throws IOException { SVGGraphics2D graphics = new SVGGraphics2D(40, 40); graphics.drawRect(10, 10, 20, 10); // 1- pixel border graphics.setColor(Color.BLUE); graphics.fillRect(11, 11, 19, 9); SVGUtils.writeToHTML(new File("test.html"), "TEST", graphics.getSVGElement()); } }

The thing to figure out (when I have time) is whether it is the filled rectangle or the lines that are offset incorrectly.

jfree commented 4 years ago

Initial analysis is that the output is correctly matching the SVG specification. So now I will look at what is the specified behaviour for Java2D (drawing of shape outline versus filling shape).

markustw commented 4 years ago

Thank you for looking into it. Maybe the following code block helps to show my initial finding:

The method drawRect of Graphics2D internally draws four lines to get a rectangle, it seems in SVG the x,y starting-coordinate of lines is not the top-left corner but the middle of the lines which causes the misaligment.:

        SVGGraphics2D graphics = new SVGGraphics2D(100, 100);

        graphics.setStroke(new BasicStroke(1.0f));
        graphics.fillRect(10, 10, 10, 10);
        graphics.drawRect(10, 25, 10, 10);
        graphics.drawLine(10, 40, 20, 40);

        graphics.setStroke(new BasicStroke(2.0f));
        graphics.fillRect(30, 10, 10, 10);
        graphics.drawRect(30, 25, 10, 10);
        graphics.drawLine(30, 40, 40, 40);

        graphics.setStroke(new BasicStroke(4.0f));
        graphics.fillRect(50, 10, 10, 10);
        graphics.drawRect(50, 25, 10, 10);
        graphics.drawLine(50, 40, 60, 40); 

-> graphics.drawRect() is creating 4 svg-line tags instead of a rect-tag with a fill color. Note that the same error is in the OrsonPdf library as well.

markustw commented 4 years ago

FYI: A workaround for a 1-pixel line stroke width using a half pixel offset in the drawLine() method of SVGGraphics2D worked for our svg exports, but potentially not in general:

@Override
public void drawLine(int x1, int y1, int x2, int y2) {
    if (this.line == null) {
        this.line = new Line2D.Double(x1 + 0.5, y1 + 0.5, x2 + 0.5, y2 + 0.5);
    } else {
        this.line.setLine(x1 + 0.5, y1 + 0.5, x2 + 0.5, y2 + 0.5);
    }
    draw(this.line);
}
mhschmieder commented 4 years ago

I've read this over a few times now, and am not sure if this developer note that I wrote years ago is pertinent, but it at least provides a hint for something else to try to see if it also accomplishes more predictable results. For me it is dead code as I converted to JavaFX and mostly transcode to AWT as-needed, so I don't have a working context for quickly testing it for SVG.

"The stroke width should be greater than 1.0, to force the internal line widening algorithm to kick in; otherwise there may be side effects causing a solid line."

So my own custom BasicStroke derived classes (such as dashed lines for highlighting), set the stroke width to the maximum of the desired width (2, 4, etc.) and 1.01. This may have related to dash patterns being honoured vs. details of how the internal AWT code (the parts that are exposed in the Java sources JAR) handles interior/exterior criteria.

My guess is that different code might kick in when the floating-point accuracy is needed, and that the 0.5 pixels you add may be a bit much for general use. Not sure if you tried just adding ".01" to the Line end points for drawLine(), but I'd be curious whether this also gives the preferred results in the SVG conversion from AWT. It might avoid some unwanted round-down side effects. I didn't see any obvious issues in publicly exposed AWT source code though.

Apologies if this ancient code that I haven't used in years, was merely compensating for cases where the Rendering Hints had set anti-aliasing to "off". But I think it did have to do with some quirks in the internals of AWT, which aren't really documented as fully as many of us would like.

The SVG implementation itself however, looks correct to me as well, based on what I see in my copy of the SVG spec.

christianAppl commented 3 years ago

I encountered this issue aswell.

Problem: The issue here is the translation from raster coordinates to a vectorial logic. (and vice versa) It is simple to visualize the problem at hand: grafik

When drawing pixel based raster images, one would assume to draw the black rectangle, so that the rectangle should be positioned at Pixel (X|Y) with certain dimensions. A path however is instead drawing the red rectangle (thickness 0) and is projecting half of the line thickness to the one and the other half of the line thickness to the other side of the path.

Methods such as "drawLine(int x1, int y1, int x2, int y2)" offer a means to define drawn paths using a raster (pixel) based logic. However SunGraphics2D for example will adapt such instructions accordingly by using "PixelDrawPipe" classes. (which are aware of features such as miters, line thickness etc.)

If we want to reimplement such a method - especially if we want to deduce the actually drawn paths instead of a rastered image - we will also have to adapt our paths according to the properties of the stroke used to draw a shape.

Meaning: We should compare the raw shape definition to the actually stroked shape and deduce deltas and resulting offsets accordingly.

Example:

@Test
public void testRect() throws Exception {
   BufferedImage image = new BufferedImage(22, 12, BufferedImage.TYPE_INT_ARGB);
   Graphics2D bimGraphics2D = image.createGraphics();
   bimGraphics2D.setColor(Color.BLACK);
   bimGraphics2D.drawRect(1, 1, 20, 10); // 1- pixel border
   bimGraphics2D.setColor(Color.BLUE);
   bimGraphics2D.fillRect(2, 2, 19, 9);
   File target = new File("somePath", "rect.png");
   ImageIO.write(image, "PNG", target);

   SVGGraphics2D svgGraphics2D = new SVGGraphics2D(22, 12);
   bimGraphics2D.setColor(Color.BLACK);
   svgGraphics2D.drawRect(1, 1, 20, 10); // 1- pixel border
   svgGraphics2D.setColor(Color.BLUE);
   svgGraphics2D.fillRect(2, 2, 19, 9);
   target = new File("somePath", "rect.svg");
   FileUtils.writeByteArrayToFile(target, svgGraphics2D.getSVGDocument().getBytes());
}

Be aware: When following a purely pixel based logic an area of 21 by 11 pixels should be sufficient to draw the drawn rectangle. However, this would actually cut of the lower and right line of our rectangle, as such dimensions would not consider the line thickness. Our actual rectangle is exceeding our expectations by 0.5 pixels on each side.

"rect.png": grafik

"rect.svg": grafik

"rect_manually_reposition_according_to_line_width.svg": grafik

The line thickness is 1 and the shape has 90 degree angles, therefore the solution is a translation in X and Y direction by half the line thickness (0.5):

<?xml version="1.0"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:jfreesvg="http://www.jfree.org/jfreesvg/svg" width="22" height="12" text-rendering="auto" shape-rendering="auto">
<defs></defs>
    <line x1="1.5" y1="1.5" x2="20.5" y2="1.5" style="stroke-width: 1.0;stroke: rgb(0,0,0);stroke-opacity: 1.0;stroke-linecap: square;stroke-miterlimit: 10" />
    <line x1="21.5" y1="1.5" x2="21.5" y2="10.5" style="stroke-width: 1.0;stroke: rgb(0,0,0);stroke-opacity: 1.0;stroke-linecap: square;stroke-miterlimit: 10" />
    <line x1="21.5" y1="11.5" x2="2.5" y2="11.5" style="stroke-width: 1.0;stroke: rgb(0,0,0);stroke-opacity: 1.0;stroke-linecap: square;stroke-miterlimit: 10" />
    <line x1="1.5" y1="11.5" x2="1.5" y2="2.5" style="stroke-width: 1.0;stroke: rgb(0,0,0);stroke-opacity: 1.0;stroke-linecap: square;stroke-miterlimit: 10" />
    <rect x="2" y="2" width="19" height="9" style="fill: rgb(0,0,255); fill-opacity: 1.0" />
</svg>

Also be aware: The fill Path does not apply a stroke (and no line thickness/miters etc.), therefore it's behaviour differs from that of the draw path. (as already described by others.)

Also be aware: Always translating by half the line thickness will most likely only solve the issue for rectangular shapes. For more complex shapes, especially with sharp angles, this would again lead to misalignments. The Bounds of the drawn path and the actually resulting stroked shape must be compared and offsets, aswell as dimensions, should always be adapted accordingly.

I will have a look into this, possibly I can provide a fix?

christianAppl commented 3 years ago

Sorry for the delay, had to check some other issues first.

The fix should be as simple as:

public void draw(Shape shape) {
   Shape strokedShape = this.stroke.createStrokedShape(shape);
   // if the current stroke is not a BasicStroke then it is handled as
   // a special case
   if (!(this.stroke instanceof BasicStroke)) {
      fill(strokedShape);
      return;
   }

   // Determine and adapt to deltas caused by the used stroke
   Rectangle2D bounds = shape.getBounds2D();
   Rectangle2D strokeBounds = strokedShape.getBounds2D();
   Shape adaptedShape = AffineTransform.getTranslateInstance(
      bounds.getMinX() - strokeBounds.getMinX(),
      bounds.getMinY() - strokeBounds.getMinY()
   ).createTransformedShape(shape);
// Use the adaptedShape instead of the original shape in all remaining lines of this method.

This assumes: If a X|Y delta can be found for minX and/or minY, when comparing the bounds of the originally drawn shape to the bounds of the stroked shape, then the path must be translated by said delta to reflect changes caused by the stroke (miter, line thickness etc.)

Evaluation: This seems to fix the issue described in this thread but further testing is required. This is the most simple solution that seemed obvious to me yesterday - therefore it still could contain errors. Also: I assume, that all other methods call this to draw stroked shapes - I could have missed something.

Resulting svg - created using "testRect()"

<?xml version="1.0"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:jfreesvg="http://www.jfree.org/jfreesvg/svg" width="22" height="12" text-rendering="auto" shape-rendering="auto">
    <defs></defs>
    <g style="stroke-width: 1.0;stroke: rgb(0,0,0);stroke-opacity: 1.0;stroke-linecap: square;stroke-miterlimit: 10; fill: none" >
        <path d="M 1.5 1.5 L 20.5 1.5"/>
    </g>
    <g style="stroke-width: 1.0;stroke: rgb(0,0,0);stroke-opacity: 1.0;stroke-linecap: square;stroke-miterlimit: 10; fill: none" >
        <path d="M 21.5 1.5 L 21.5 10.5"/>
        </g>
    <g style="stroke-width: 1.0;stroke: rgb(0,0,0);stroke-opacity: 1.0;stroke-linecap: square;stroke-miterlimit: 10; fill: none" >
        <path d="M 21.5 11.5 L 2.5 11.5"/>
    </g>
    <g style="stroke-width: 1.0;stroke: rgb(0,0,0);stroke-opacity: 1.0;stroke-linecap: square;stroke-miterlimit: 10; fill: none" >
        <path d="M 1.5 11.5 L 1.5 2.5"/>
    </g>
    <rect x="2" y="2" width="19" height="9" style="fill: rgb(0,0,255); fill-opacity: 1.0" />
</svg>

grafik

Possibly an issue: By altering the shape, it now is treated differently, and creates "path" elements instead of "line" elements, which is also causing the creation of additional (and identical) "g" elements... which is unfortunate. Possibly those could be merged to a common "g" parent? If my suggestion should be used to solve this: Further optimization is possible here.

FYI: A more complex shape, that is positioned correctly using this approach: grafik

christianAppl commented 3 years ago

Using JFreeSVG 5.0 I can reproduce this issue no longer. From my point of view this issue seems to be fixed.

Btw. thank you very much for 5.0 - currently there is no longer a need for my "local workarrounds", as this adresses all issues I have previously found.

Time to purchase the commercial license. :)

markustw commented 2 years ago

Thank you for the new release. But I still have the issue, did you configured some additional rendering hints on the SVGGraphics or some other settings ? If you run the Test (public void testRect() ...) from "christianAppl commented on Mar 17" we still get the 0.5 pixel offset.