From 951f1658276e72ef5c91c63f13c406b78aeaa008 Mon Sep 17 00:00:00 2001 From: PHJ2000 <105151063+PHJ2000@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:06:04 +0900 Subject: [PATCH] Add explicit export dimensions to ChartEncoder --- README.md | 14 ++ .../standalone/issues/TestForIssue807.java | 30 ++++ .../java/org/knowm/xchart/ChartEncoder.java | 116 +++++++++++-- .../org/knowm/xchart/ChartEncoderTest.java | 162 +++++++++++++++++- 4 files changed, 304 insertions(+), 18 deletions(-) create mode 100644 xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue807.java diff --git a/README.md b/README.md index 56140d4a8..c0af5c9c3 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,20 @@ ChartEncoder.saveChart(chart, outputStream, "png"); byte[] bytes = ChartEncoder.getBytes(chart, "png"); ``` +To choose the output dimensions independently of the chart's construction size, pass an explicit +width and height: + +```java +ChartEncoder.saveChart(chart, "./MyChart", "png", 1920, 1080); +ChartEncoder.saveChart(chart, outputStream, "svg", 800, 600); +byte[] bytes = ChartEncoder.getBytes(chart, "png", 1280, 720); +``` + +Both dimensions must be positive. Raster dimensions are in pixels; vector formats use their +existing rendering units. The chart is laid out at the requested dimensions rather than scaling +an existing image. Rendering follows `IChart.paint`: XChart charts retain the most recently painted +width and height, so later exports without explicit dimensions use that size. + * **Raster formats** (`png`, `jpg`, `bmp`, `gif`, `tiff`) are delegated to `javax.imageio.ImageIO`. Any other format for which an `ImageWriter` plugin is registered on the classpath works too — e.g. add `com.github.usefulness:webp-imageio` to export `webp` (see the `ExampleWebP` demo), or an AVIF plugin to export `avif`. Call `ChartEncoder.getSupportedRasterFormats()` to see what the current classpath can write. For AVIF, where no bundleable ImageIO writer exists yet, the `ExampleAvif` demo shows a portable fallback that pipes a PNG through the `avifenc` CLI (`brew install libavif`). * **Vector formats** (`svg`, `eps`, `pdf`) require the optional dependencies below. diff --git a/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue807.java b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue807.java new file mode 100644 index 000000000..7df50d87b --- /dev/null +++ b/xchart-demo/src/main/java/org/knowm/xchart/standalone/issues/TestForIssue807.java @@ -0,0 +1,30 @@ +package org.knowm.xchart.standalone.issues; + +import java.io.IOException; +import org.knowm.xchart.ChartEncoder; +import org.knowm.xchart.SwingWrapper; +import org.knowm.xchart.XYChart; +import org.knowm.xchart.XYChartBuilder; + +public class TestForIssue807 { + + public static void main(String[] args) throws IOException { + XYChart chart = getChart(); + ChartEncoder.saveChart(chart, "chart-1280x720", "png", 1280, 720); + new SwingWrapper<>(chart).displayChart(); + } + + /** Constructs the chart without launching a window. */ + public static XYChart getChart() { + XYChart chart = + new XYChartBuilder() + .width(640) + .height(480) + .title("Export at a chosen size") + .xAxisTitle("Time") + .yAxisTitle("Value") + .build(); + chart.addSeries("Sample", new double[] {0, 1, 2, 3, 4}, new double[] {1, 3, 2, 5, 4}); + return chart; + } +} diff --git a/xchart/src/main/java/org/knowm/xchart/ChartEncoder.java b/xchart/src/main/java/org/knowm/xchart/ChartEncoder.java index df43e2bf5..15318f431 100644 --- a/xchart/src/main/java/org/knowm/xchart/ChartEncoder.java +++ b/xchart/src/main/java/org/knowm/xchart/ChartEncoder.java @@ -1,5 +1,6 @@ package org.knowm.xchart; +import java.awt.Graphics2D; import java.io.ByteArrayOutputStream; import java.io.FileOutputStream; import java.io.IOException; @@ -13,7 +14,8 @@ import org.knowm.xchart.internal.chartpart.IChart; /** - * Unified, format-agnostic entry point for exporting a {@link org.knowm.xchart.internal.chartpart.IChart}. + * Unified, format-agnostic entry point for exporting a {@link + * org.knowm.xchart.internal.chartpart.IChart}. * *

A single {@code format} string selects the encoder: * @@ -23,10 +25,10 @@ *

  • {@code "pdf"} → {@link PdfboxGraphicsEncoder} (optional {@code * de.rototor.pdfbox:graphics2d}) *
  • any other name → the raster path, delegating to {@link javax.imageio.ImageIO}. This - * covers the JDK's built-in writers ({@code png}, {@code jpg}/{@code jpeg}, {@code bmp}, {@code - * gif}, {@code tiff}/{@code tif}, {@code wbmp}) plus any additional format for which - * an ImageIO plugin is registered on the classpath (e.g. {@code webp}, {@code avif} via a - * TwelveMonkeys / native plugin). + * covers the JDK's built-in writers ({@code png}, {@code jpg}/{@code jpeg}, {@code bmp}, + * {@code gif}, {@code tiff}/{@code tif}, {@code wbmp}) plus any additional format + * for which an ImageIO plugin is registered on the classpath (e.g. {@code webp}, {@code avif} + * via a TwelveMonkeys / native plugin). * * *

    Because raster export is delegated to ImageIO's pluggable Service Provider Interface, new @@ -47,8 +49,8 @@ private ChartEncoder() {} * * @param chart the chart to export * @param fileName target file name, with or without extension - * @param format the format name, e.g. {@code "png"}, {@code "svg"}, {@code "pdf"}, {@code "tiff"}, - * {@code "webp"} + * @param format the format name, e.g. {@code "png"}, {@code "svg"}, {@code "pdf"}, {@code + * "tiff"}, {@code "webp"} * @throws IOException if writing fails or no encoder is available for the format */ public static void saveChart(IChart chart, String fileName, String format) throws IOException { @@ -65,8 +67,8 @@ public static void saveChart(IChart chart, String fileName, String format) throw * * @param chart the chart to export * @param out the target stream - * @param format the format name, e.g. {@code "png"}, {@code "svg"}, {@code "pdf"}, {@code "tiff"}, - * {@code "webp"} + * @param format the format name, e.g. {@code "png"}, {@code "svg"}, {@code "pdf"}, {@code + * "tiff"}, {@code "webp"} * @throws IOException if writing fails or no encoder is available for the format */ @SuppressWarnings("deprecation") @@ -103,6 +105,96 @@ public static byte[] getBytes(IChart chart, String format) throws IOException { } } + /** + * Save a chart to a file at an explicit rendering size. The extension is appended if missing. + * + *

    The chart is laid out at the requested size, not scaled from a previously rendered image. As + * with {@link IChart#paint(Graphics2D, int, int)}, rendering may update the chart's dimensions. + * + * @param chart the chart to export + * @param fileName target file name, with or without extension + * @param format the format name, e.g. {@code "png"}, {@code "svg"}, or {@code "pdf"} + * @param width positive rendering width (pixels for raster formats) + * @param height positive rendering height (pixels for raster formats) + * @throws IllegalArgumentException if either dimension is not positive + * @throws IOException if writing fails or no encoder is available for the format + */ + public static void saveChart(IChart chart, String fileName, String format, int width, int height) + throws IOException { + + saveChart(withSize(chart, width, height), fileName, format); + } + + /** + * Write a chart to a stream at an explicit rendering size. Does not close the target stream. + * + *

    The chart is laid out at the requested size, not scaled from a previously rendered image. As + * with {@link IChart#paint(Graphics2D, int, int)}, rendering may update the chart's dimensions. + * + * @param chart the chart to export + * @param out the target stream + * @param format the format name, e.g. {@code "png"}, {@code "svg"}, or {@code "pdf"} + * @param width positive rendering width (pixels for raster formats) + * @param height positive rendering height (pixels for raster formats) + * @throws IllegalArgumentException if either dimension is not positive + * @throws IOException if writing fails or no encoder is available for the format + */ + public static void saveChart(IChart chart, OutputStream out, String format, int width, int height) + throws IOException { + + saveChart(withSize(chart, width, height), out, format); + } + + /** + * Generate encoded chart bytes at an explicit rendering size. + * + *

    The chart is laid out at the requested size, not scaled from a previously rendered image. As + * with {@link IChart#paint(Graphics2D, int, int)}, rendering may update the chart's dimensions. + * + * @param chart the chart to export + * @param format the format name + * @param width positive rendering width (pixels for raster formats) + * @param height positive rendering height (pixels for raster formats) + * @return the encoded image bytes + * @throws IllegalArgumentException if either dimension is not positive + * @throws IOException if writing fails or no encoder is available for the format + */ + public static byte[] getBytes(IChart chart, String format, int width, int height) + throws IOException { + + return getBytes(withSize(chart, width, height), format); + } + + private static IChart withSize(IChart chart, int width, int height) { + if (width <= 0 || height <= 0) { + throw new IllegalArgumentException("Width and height must be positive"); + } + return new IChart() { + @Override + public void paint(Graphics2D graphics, int paintWidth, int paintHeight) { + chart.paint(graphics, paintWidth, paintHeight); + } + + @Override + public int getWidth() { + + return width; + } + + @Override + public int getHeight() { + + return height; + } + + @Override + public String getTitle() { + + return chart.getTitle(); + } + }; + } + /** Raster export via ImageIO's pluggable writers. */ private static void saveRaster(IChart chart, OutputStream out, String format) throws IOException { @@ -134,9 +226,9 @@ private static void saveRaster(IChart chart, OutputStream out, String format) th } /** - * Returns the sorted set of raster format names that can be written on the current classpath. This - * reflects the JDK's built-in writers plus any registered ImageIO plugins, but not the vector - * formats ({@code svg}, {@code eps}, {@code pdf}). + * Returns the sorted set of raster format names that can be written on the current classpath. + * This reflects the JDK's built-in writers plus any registered ImageIO plugins, but not the + * vector formats ({@code svg}, {@code eps}, {@code pdf}). * * @return the available raster format names, lower-cased */ diff --git a/xchart/src/test/java/org/knowm/xchart/ChartEncoderTest.java b/xchart/src/test/java/org/knowm/xchart/ChartEncoderTest.java index ecb0a73dd..74a4ecadf 100644 --- a/xchart/src/test/java/org/knowm/xchart/ChartEncoderTest.java +++ b/xchart/src/test/java/org/knowm/xchart/ChartEncoderTest.java @@ -1,23 +1,38 @@ package org.knowm.xchart; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.imageio.ImageIO; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.knowm.xchart.internal.chartpart.IChart; import org.knowm.xchart.style.markers.SeriesMarkers; /** - * Exercises the unified {@link ChartEncoder} across every export format: the built-in raster formats - * provided by ImageIO on the current JDK (png, jpg, bmp, gif, tiff, wbmp, plus any registered - * plugin), and the optional vector formats (svg, eps, pdf). Also serves as a generator that writes - * one sample chart per supported format, mirroring the {@code Sample_Chart.*} files produced by the - * demo. + * Exercises the unified {@link ChartEncoder} across every export format: the built-in raster + * formats provided by ImageIO on the current JDK (png, jpg, bmp, gif, tiff, wbmp, plus any + * registered plugin), and the optional vector formats (svg, eps, pdf). Also serves as a generator + * that writes one sample chart per supported format, mirroring the {@code Sample_Chart.*} files + * produced by the demo. */ public class ChartEncoderTest { @@ -58,7 +73,9 @@ public void jdkProvidesTiffWriter() { assertTrue(ChartEncoder.getSupportedRasterFormats().contains("tiff")); } - /** The optional vector formats are on XChart's own test classpath, so they must produce output. */ + /** + * The optional vector formats are on XChart's own test classpath, so they must produce output. + */ @Test public void writesVectorFormats(@TempDir Path dir) throws IOException { @@ -99,4 +116,137 @@ public void unsupportedFormatThrowsHelpfulError(@TempDir Path dir) { assertTrue(e.getMessage().toLowerCase().contains("avif")); assertTrue(e.getMessage().contains("plugin")); } + + @Test + public void explicitSizeLaysOutDifferentChartTypes() throws IOException { + PieChart pie = new PieChart(500, 400); + pie.addSeries("A", 3); + pie.addSeries("B", 2); + CategoryChart bars = new CategoryChart(500, 400); + bars.addSeries("Sample", new double[] {1, 2, 3}, new double[] {2, 4, 3}); + for (IChart chart : new IChart[] {sampleChart(), pie, bars}) { + for (int[] size : new int[][] {{800, 300}, {300, 600}}) { + BufferedImage image = + ImageIO.read( + new ByteArrayInputStream(ChartEncoder.getBytes(chart, "png", size[0], size[1]))); + assertEquals(size[0], image.getWidth()); + assertEquals(size[1], image.getHeight()); + // Existing chart implementations retain their most recent paint dimensions. + assertEquals(size[0], chart.getWidth()); + assertEquals(size[1], chart.getHeight()); + } + } + } + + @Test + public void explicitSizeIsPassedToPaintWithoutScaling() throws IOException { + IChart chart = + new IChart() { + @Override + public int getWidth() { + return 100; + } + + @Override + public int getHeight() { + return 100; + } + + @Override + public String getTitle() { + return "Size probe"; + } + + @Override + public void paint(Graphics2D graphics, int width, int height) { + assertEquals(240, width); + assertEquals(120, height); + assertEquals(1.0, graphics.getTransform().getScaleX()); + assertEquals(1.0, graphics.getTransform().getScaleY()); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, width, height); + graphics.setColor(Color.RED); + graphics.fillRect(width - 1, height - 1, 1, 1); + } + }; + BufferedImage image = + ImageIO.read(new ByteArrayInputStream(ChartEncoder.getBytes(chart, "png", 240, 120))); + assertEquals(Color.RED.getRGB(), image.getRGB(239, 119)); + assertEquals(Color.WHITE.getRGB(), image.getRGB(238, 119)); + } + + @Test + public void explicitSizeStreamStaysOpen() throws IOException { + class Target extends ByteArrayOutputStream { + boolean closed; + + @Override + public void close() { + closed = true; + } + } + Target target = new Target(); + ChartEncoder.saveChart(sampleChart(), target, "jpg", 720, 360); + assertFalse(target.closed); + BufferedImage image = ImageIO.read(new ByteArrayInputStream(target.toByteArray())); + assertEquals(720, image.getWidth()); + assertEquals(360, image.getHeight()); + } + + @Test + public void explicitSizeFileAppendsExtension(@TempDir Path dir) throws IOException { + String base = dir.resolve("sized").toString(); + ChartEncoder.saveChart(sampleChart(), base, "PNG", 720, 360); + BufferedImage image = ImageIO.read(new File(base + ".png")); + assertEquals(720, image.getWidth()); + assertEquals(360, image.getHeight()); + } + + @Test + public void explicitSizeControlsVectorPageDimensions() throws IOException { + byte[] svg = ChartEncoder.getBytes(sampleChart(), "svg", 720, 360); + Matcher viewBox = + Pattern.compile("viewBox=\"([^\"]+)\"").matcher(new String(svg, StandardCharsets.UTF_8)); + assertTrue(viewBox.find()); + String[] bounds = viewBox.group(1).trim().split("\\s+"); + assertEquals(720.0, Double.parseDouble(bounds[2])); + assertEquals(360.0, Double.parseDouble(bounds[3])); + try (PDDocument pdf = Loader.loadPDF(ChartEncoder.getBytes(sampleChart(), "pdf", 720, 360))) { + assertEquals(720.0f, pdf.getPage(0).getMediaBox().getWidth()); + assertEquals(360.0f, pdf.getPage(0).getMediaBox().getHeight()); + } + } + + @Test + public void invalidSizeDoesNotWriteOrTruncateOutput(@TempDir Path dir) throws IOException { + XYChart chart = sampleChart(); + Path file = dir.resolve("existing.png"); + byte[] original = {1, 2, 3}; + Files.write(file, original); + for (int[] size : new int[][] {{0, 100}, {100, 0}, {-1, 100}, {100, -1}}) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows( + IllegalArgumentException.class, + () -> ChartEncoder.saveChart(chart, file.toString(), "png", size[0], size[1])); + assertThrows( + IllegalArgumentException.class, + () -> ChartEncoder.saveChart(chart, out, "png", size[0], size[1])); + assertThrows( + IllegalArgumentException.class, + () -> ChartEncoder.getBytes(chart, "png", size[0], size[1])); + assertEquals(0, out.size()); + assertArrayEquals(original, Files.readAllBytes(file)); + assertEquals(500, chart.getWidth()); + assertEquals(400, chart.getHeight()); + } + } + + @Test + public void explicitSizePreservesUnsupportedFormatErrors() { + IOException error = + assertThrows( + IOException.class, + () -> ChartEncoder.getBytes(sampleChart(), "not-a-format", 720, 360)); + assertTrue(error.getMessage().contains("not-a-format")); + } }