diff --git a/README.md b/README.md
index 1686a31f..56140d4a 100644
--- a/README.md
+++ b/README.md
@@ -687,7 +687,7 @@ Add the XChart library as a dependency to your pom.xml file:
org.knowm.xchart
xchart
- 4.0.3
+ 4.0.4
```
@@ -710,7 +710,7 @@ For snapshots, add the following to your pom.xml file:
org.knowm.xchart
xchart
-4.0.4-SNAPSHOT
+4.0.5-SNAPSHOT
```
diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickMarks.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickMarks.java
index beecb31d..6c56d18c 100644
--- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickMarks.java
+++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/AxisTickMarks.java
@@ -105,15 +105,17 @@ else if (direction == Axis_.Direction.X && styler.isXAxisTicksVisible()) {
g.setColor(styler.getXAxisTickMarksColor());
int axisTickMarkLength = styler.getAxisTickMarkLength();
double xOffset = chart.getXAxis().getBounds().getX();
- double yOffset =
- chart.getXAxis().getAxisTick().getAxisTickLabels().getBounds().getY()
- - styler.getAxisTickPadding();
+ // anchor the axis line to the top of the x-axis bounds — which sits exactly plotMargin
+ // below the plot area — rather than deriving it from the tick label geometry, whose
+ // measured height can drift sub-pixel from the reserved bounds and shift the line
+ double lineYOffset = chart.getXAxis().getBounds().getY();
+ double yOffset = lineYOffset + axisTickMarkLength;
// bounds
bounds =
new Rectangle2D.Double(
xOffset,
- yOffset - axisTickMarkLength,
+ lineYOffset,
chart.getXAxis().getBounds().getWidth(),
axisTickMarkLength);
// g.setColor(Color.yellow);
@@ -145,11 +147,13 @@ else if (direction == Axis_.Direction.X && styler.isXAxisTicksVisible()) {
if (styler.isAxisTicksLineVisible()) {
g.setStroke(styler.getAxisTickMarksStroke());
- g.drawLine(
- (int) xOffset,
- (int) (yOffset - axisTickMarkLength),
- (int) (xOffset + chart.getXAxis().getBounds().getWidth()),
- (int) (yOffset - axisTickMarkLength));
+ Shape line =
+ new Line2D.Double(
+ xOffset,
+ lineYOffset,
+ xOffset + chart.getXAxis().getBounds().getWidth(),
+ lineYOffset);
+ g.draw(line);
}
} else {
bounds = new Rectangle2D.Double();
diff --git a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Axis_Y.java b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Axis_Y.java
index 8a8151c0..20dd1bee 100644
--- a/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Axis_Y.java
+++ b/xchart/src/main/java/org/knowm/xchart/internal/chartpart/Axis_Y.java
@@ -168,25 +168,17 @@ public void paint(java.awt.Graphics2D g) {
double axisTickLabelsHeight = 0.0;
if (axesChartStyler.isYAxisTicksVisible()) {
- // find the widest label using the actual renderer (TeX or plain text)
+ // find the widest label using the same metric AxisTickLabels paints with — any mismatch
+ // between this hint and the painted column shifts the axis line away from its intended
+ // plotMargin distance to the plot area
+ FontRenderContext frc = new FontRenderContext(null, true, false);
double maxLabelWidth = 0;
for (int i = 0; i < axisTickCalculator.getTickLabels().size(); i++) {
String lbl = axisTickCalculator.getTickLabels().get(i);
if (lbl == null || lbl.isEmpty()) {
continue;
}
- double w;
- if (TexRenderer.isTeX(lbl)) {
- w = TexRenderer.getBounds(lbl, axesChartStyler.getAxisTickLabelsFont()).getWidth();
- } else {
- w =
- new TextLayout(
- lbl,
- axesChartStyler.getAxisTickLabelsFont(),
- new FontRenderContext(null, true, false))
- .getBounds()
- .getWidth();
- }
+ double w = TickLabelMetrics.width(lbl, axesChartStyler.getAxisTickLabelsFont(), frc);
if (w > maxLabelWidth) {
maxLabelWidth = w;
}
@@ -214,18 +206,7 @@ public void paint(java.awt.Graphics2D g) {
if (lbl == null || lbl.isEmpty()) {
continue;
}
- double w;
- if (TexRenderer.isTeX(lbl)) {
- w = TexRenderer.getBounds(lbl, axesChartStyler.getAxisTickLabelsFont()).getWidth();
- } else {
- w =
- new TextLayout(
- lbl,
- axesChartStyler.getAxisTickLabelsFont(),
- new FontRenderContext(null, true, false))
- .getBounds()
- .getWidth();
- }
+ double w = TickLabelMetrics.width(lbl, axesChartStyler.getAxisTickLabelsFont(), frc);
if (w > slaveMaxWidth) {
slaveMaxWidth = w;
}
diff --git a/xchart/src/test/java/org/knowm/xchart/internal/chartpart/AxisLinePlotMarginTest.java b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/AxisLinePlotMarginTest.java
new file mode 100644
index 00000000..e7a5d557
--- /dev/null
+++ b/xchart/src/test/java/org/knowm/xchart/internal/chartpart/AxisLinePlotMarginTest.java
@@ -0,0 +1,85 @@
+package org.knowm.xchart.internal.chartpart;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.awt.Color;
+import java.awt.image.BufferedImage;
+import org.junit.jupiter.api.Test;
+import org.knowm.xchart.BitmapEncoder;
+import org.knowm.xchart.XYChart;
+import org.knowm.xchart.XYChartBuilder;
+
+// The axis tick lines that hug the plot area must sit at the same distance (plotMargin) from the
+// plot on both the y-axis and x-axis sides. Historically the y-axis line was positioned from the
+// painted tick label width while the plot edge was positioned from the width *hint*; when the two
+// measurements diverged (e.g. advance width vs ink width after issue #618), the y-axis line
+// drifted toward the plot and the gaps went visibly asymmetric.
+//
+// Renders to an off-screen image (no XChartPanel / Swing display) so it runs headless on CI.
+class AxisLinePlotMarginTest {
+
+ @Test
+ void axisLinesSitAtPlotMarginOnBothSides() throws Exception {
+
+ XYChart chart = new XYChartBuilder().width(800).height(600).title("t").build();
+ chart.addSeries("s", new double[] {1, 2, 3}, new double[] {10, 20, 30});
+ chart.getStyler().setLegendVisible(false);
+ chart.getStyler().setPlotGridLinesVisible(false);
+ chart.getStyler().setPlotBackgroundColor(Color.YELLOW);
+ chart.getStyler().setPlotBorderVisible(true);
+ chart.getStyler().setPlotBorderColor(Color.RED);
+
+ BufferedImage img = BitmapEncoder.getBufferedImage(chart);
+
+ // locate the yellow plot area
+ int yellow = Color.YELLOW.getRGB();
+ int minX = Integer.MAX_VALUE, maxX = -1, minY = Integer.MAX_VALUE, maxY = -1;
+ for (int y = 0; y < img.getHeight(); y++) {
+ for (int x = 0; x < img.getWidth(); x++) {
+ if (img.getRGB(x, y) == yellow) {
+ minX = Math.min(minX, x);
+ maxX = Math.max(maxX, x);
+ minY = Math.min(minY, y);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+ assertThat(maxX).as("plot area must be found").isGreaterThan(minX);
+
+ // walk left from the plot border to the y-axis line (first non-background pixel)
+ int borderLeft = minX - 1;
+ int background = img.getRGB(0, 0);
+ int midY = (minY + maxY) / 2;
+ int yAxisLineX = -1;
+ for (int x = borderLeft - 1; x > borderLeft - 20; x--) {
+ if (img.getRGB(x, midY) != background) {
+ yAxisLineX = x;
+ break;
+ }
+ }
+ assertThat(yAxisLineX).as("y-axis line must be found left of the plot").isPositive();
+
+ // walk down from the plot border to the x-axis line; use a column 1/3 into the plot to make a
+ // tick mark collision unlikely (tick marks are only a few px wide, and if one were hit it
+ // would not change the first dark pixel: marks start at the line and extend away from it)
+ int borderBottom = maxY + 1;
+ int midX = minX + (maxX - minX) / 3;
+ int xAxisLineY = -1;
+ for (int y = borderBottom + 1; y < borderBottom + 20; y++) {
+ if (img.getRGB(midX, y) != background) {
+ xAxisLineY = y;
+ break;
+ }
+ }
+ assertThat(xAxisLineY).as("x-axis line must be found below the plot").isPositive();
+
+ int leftGap = borderLeft - yAxisLineX;
+ int bottomGap = xAxisLineY - borderBottom;
+ assertThat(leftGap)
+ .as("y-axis line and x-axis line must sit at the same distance from the plot area")
+ .isEqualTo(bottomGap);
+ assertThat(leftGap)
+ .as("axis line distance must equal the styler's plotMargin")
+ .isEqualTo(chart.getStyler().getPlotMargin());
+ }
+}