From 348be3b9f0961697757b3b2870f1debad8eb8022 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 22 Jun 2026 22:42:49 +0100 Subject: [PATCH 1/2] feat(api): render a DocumentSession directly to images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Getting a raster from a document meant calling toPdfBytes() and then reloading those bytes with Loader.loadPDF to hand a PDDocument to PDFRenderer — a serialize-then-reparse round-trip. Add DocumentSession.toImages(int dpi) and toImage(int pageIndex, int dpi), plus transparent ARGB overloads, returning java.awt.image.BufferedImage and rasterizing the in-memory document directly. The PDF backend's document build is extracted into buildDocument(...), shared by the existing save path and a new renderToImages(...); the PDFRenderer/ImageType calls stay in the backend, so the public surface stays PDFBox-free (BufferedImage is JDK). PdfVisualRegression gains direct renderPages(session) / assertMatchesBaseline(name, session) overloads, and EmojiSvgVsPngExample rasterises through toImage instead of the round-trip. --- CHANGELOG.md | 18 ++ docs/recipes/streaming.md | 32 +++ .../features/text/EmojiSvgVsPngExample.java | 12 +- .../document/api/DocumentRenderingFacade.java | 24 +++ .../compose/document/api/DocumentSession.java | 107 +++++++++- .../fixed/pdf/PdfFixedLayoutBackend.java | 68 ++++++- .../testing/visual/PdfVisualRegression.java | 40 +++- .../api/DocumentSessionImageTest.java | 189 ++++++++++++++++++ 8 files changed, 476 insertions(+), 14 deletions(-) create mode 100644 src/test/java/com/demcha/compose/document/api/DocumentSessionImageTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f9ebe07..4865c793e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,16 @@ PDF `GoTo` actions. External links are unchanged. begin/end marker per icon — so it matches the inline fix above. The same flag is exposed to the DSL as `LayerStackBuilder.clipToBounds()` — the `overflow: hidden` of a stacking box for any layer stack. +- **Render a document straight to images** (`@since 1.9.0`). `DocumentSession` + gains `toImages(int dpi)` → `List` (one per page) and + `toImage(int pageIndex, int dpi)` → `BufferedImage`, plus `transparent` + overloads (`toImages(dpi, transparent)` / `toImage(pageIndex, dpi, transparent)`) + that return ARGB instead of opaque white. These rasterize the in-memory document + directly, skipping the previous `toPdfBytes()` → reparse round-trip needed to get + a preview or thumbnail. The return type is the JDK `java.awt.image.BufferedImage`, + so the public surface stays renderer-agnostic; the PDFBox `PDFRenderer` call lives + in the PDF backend. `PdfVisualRegression` also gains direct `renderPages(session)` / + `assertMatchesBaseline(name, session)` overloads on the same path. ### Documentation @@ -167,6 +177,14 @@ PDF `GoTo` actions. External links are unchanged. rasterizes a colour glyph, a gradient emoji paints its shading, an unknown shortcode falls back to literal text, and `RichText.emoji` yields an `InlineSvgRun` or a text run accordingly). +- `DocumentSessionImageTest` (direct render-to-image): `toImages(dpi)` returns one + image per page sized to the page at that DPI; dimensions scale with DPI; rendered + pages contain painted (non-background) pixels; `transparent` yields an ARGB image + with a fully-transparent margin while the default is opaque RGB; `toImage(pageIndex, + dpi)` returns the requested page and is pixel-identical to the matching `toImages` + entry; a post-processed watermark also lands in the raster; the direct render is + pixel-identical to the `toPdfBytes()` round-trip (`PdfVisualRegression` / `ImageDiff`, + budget 0); and `dpi <= 0`, an out-of-range page, and an empty document are rejected. ## v1.8.0 — 2026-06-18 diff --git a/docs/recipes/streaming.md b/docs/recipes/streaming.md index 5ac30f157..473c58f50 100644 --- a/docs/recipes/streaming.md +++ b/docs/recipes/streaming.md @@ -76,6 +76,38 @@ s3.putObject(bucket, key, RequestBody.fromBytes(pdfBytes)); with an explicit stream — the in-memory path holds the entire PDF before returning. +## Page images (previews and thumbnails) + +When you need a raster image of the document — a preview, a thumbnail, +a pixel diff — render straight to `java.awt.image.BufferedImage` with +`toImages(int dpi)` (one image per page) or `toImage(int pageIndex, int dpi)` +(a single page). This rasterizes the in-memory document directly, so you +skip the `toPdfBytes()` → re-parse round-trip you'd otherwise need. + +```java +import java.awt.image.BufferedImage; +import javax.imageio.ImageIO; + +try (DocumentSession document = GraphCompose.document().create()) { + document.pageFlow(page -> page.module("Summary", + module -> module.paragraph("Preview me"))); + + List pages = document.toImages(150); // 150 DPI + for (int i = 0; i < pages.size(); i++) { + ImageIO.write(pages.get(i), "png", Path.of("page-" + i + ".png").toFile()); + } +} +``` + +The background is opaque white by default. Pass `transparent = true` +(`toImages(dpi, true)` / `toImage(pageIndex, dpi, true)`) to get an ARGB +image with a transparent background instead — useful when compositing a +single glyph or badge. + +The return type is the JDK `BufferedImage`, so this stays free of any +PDF-renderer types in your call site. (`dpi` must be `> 0`; `pageIndex` +must be in range.) + ## DOCX semantic export `DocxSemanticBackend` produces an editable Word document. Apache POI diff --git a/examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java b/examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java index 03b094636..596f53fc5 100644 --- a/examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java +++ b/examples/src/main/java/com/demcha/examples/features/text/EmojiSvgVsPngExample.java @@ -18,10 +18,6 @@ import com.demcha.compose.document.table.DocumentTableStyle; import com.demcha.compose.font.FontName; import com.demcha.examples.support.ExampleOutputPaths; -import org.apache.pdfbox.Loader; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.rendering.ImageType; -import org.apache.pdfbox.rendering.PDFRenderer; import javax.imageio.ImageIO; import java.awt.image.BufferedImage; @@ -146,15 +142,13 @@ private static DocumentNode pngCell(byte[] png) { /** Renders the glyph alone through the engine, then rasterises that page to PNG bytes. */ private static byte[] rasterise(SvgIcon icon) throws Exception { double box = 24.0; - byte[] glyphPdf; try (DocumentSession g = GraphCompose.document().pageSize(box, box).margin(0, 0, 0, 0).create()) { g.dsl().pageFlow().name("g") .addParagraph(p -> p.inlineSvgIcon(icon, box).margin(DocumentInsets.zero())) .build(); - glyphPdf = g.toPdfBytes(); - } - try (PDDocument doc = Loader.loadPDF(glyphPdf)) { - BufferedImage image = new PDFRenderer(doc).renderImageWithDPI(0, 96f, ImageType.ARGB); + // Rasterise the in-memory page directly (transparent ARGB) — no PDF + // byte round-trip. + BufferedImage image = g.toImage(0, 96, true); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); ImageIO.write(image, "png", buffer); return buffer.toByteArray(); diff --git a/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java b/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java index fc1db5beb..cd6ca3c21 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java @@ -13,6 +13,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.nio.file.Files; @@ -174,6 +175,29 @@ void buildPdf(Path outputFile) throws Exception { } } + List renderImages(int dpi, boolean transparent, int pageIndex) throws Exception { + context.ensureOpen(); + context.ensureRenderable(); + long startNanos = System.nanoTime(); + LIFECYCLE_LOG.debug("document.images.start sessionId={} revision={} roots={} dpi={} transparent={} pageIndex={}", + context.sessionId(), context.revision(), context.rootCount(), dpi, transparent, pageIndex); + try { + List images = context.conveniencePdfBackend().renderToImages( + context.layoutGraph(), + new FixedLayoutRenderContext(context.canvas(), context.customFontFamilies(), null, null), + dpi, + transparent, + pageIndex); + LIFECYCLE_LOG.debug("document.images.end sessionId={} revision={} pageCount={} durationMs={}", + context.sessionId(), context.revision(), images.size(), elapsedMillis(startNanos)); + return images; + } catch (Exception ex) { + LIFECYCLE_LOG.error("document.images.failed sessionId={} revision={} errorType={}", + context.sessionId(), context.revision(), ex.getClass().getSimpleName(), ex); + throw ex; + } + } + /** * Context callbacks that the rendering facade reads from * {@link DocumentSession}. The session implements this interface so the diff --git a/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/src/main/java/com/demcha/compose/document/api/DocumentSession.java index d166274ef..c8dca031f 100644 --- a/src/main/java/com/demcha/compose/document/api/DocumentSession.java +++ b/src/main/java/com/demcha/compose/document/api/DocumentSession.java @@ -25,6 +25,7 @@ import org.slf4j.LoggerFactory; import java.awt.*; +import java.awt.image.BufferedImage; import java.io.OutputStream; import java.nio.file.Path; import java.util.ArrayList; @@ -133,6 +134,22 @@ private static R wrapPdfRendering(String action, PdfRenderingBody body) t } } + /** + * Image-rendering analogue of {@link #wrapPdfRendering}: rewraps any + * underlying checked {@link Exception} as {@link DocumentRenderingException} + * while letting {@link RuntimeException}s (e.g. argument/state validation) + * propagate unchanged. + */ + private static R wrapImageRendering(String action, ImageRenderingBody body) throws DocumentRenderingException { + try { + return body.run(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new DocumentRenderingException("Failed to " + action + ": " + e.getMessage(), e); + } + } + /** * Returns the fluent semantic DSL facade bound to this session. * @@ -857,6 +874,89 @@ public void buildPdf(Path outputFile) throws DocumentRenderingException { }); } + /** + * Renders every page of the current document to a raster image at the given + * resolution, with an opaque white background. + * + *

Renders the in-memory document directly to images — without the + * intermediate PDF byte array of {@link #toPdfBytes()} followed by a + * {@code Loader.loadPDF(...)} re-parse — so it avoids that serialize-then-reparse + * round-trip. Useful for page previews, thumbnails, and pixel diffing.

+ * + * @param dpi target resolution in dots per inch (72 = native page size); must be {@code > 0} + * @return one image per page, in page order + * @throws IllegalArgumentException if {@code dpi <= 0} + * @throws DocumentRenderingException if rendering fails + * @since 1.9.0 + */ + public List toImages(int dpi) throws DocumentRenderingException { + return toImages(dpi, false); + } + + /** + * Renders every page of the current document to a raster image at the given + * resolution. See {@link #toImages(int)} for the no-round-trip rationale. + * + * @param dpi target resolution in dots per inch; must be {@code > 0} + * @param transparent {@code true} for a transparent (ARGB) background, {@code false} for opaque white (RGB) + * @return one image per page, in page order + * @throws IllegalArgumentException if {@code dpi <= 0} + * @throws DocumentRenderingException if rendering fails + * @since 1.9.0 + */ + public List toImages(int dpi, boolean transparent) throws DocumentRenderingException { + requirePositiveDpi(dpi); + return wrapImageRendering("render images at " + dpi + " DPI", + () -> renderingFacade.renderImages(dpi, transparent, -1)); + } + + /** + * Renders a single page of the current document to a raster image at the given + * resolution, with an opaque white background. + * + *

Each call rebuilds the whole document, so to rasterize several pages prefer + * {@link #toImages(int)} — one build, every page.

+ * + * @param pageIndex zero-based page index + * @param dpi target resolution in dots per inch; must be {@code > 0} + * @return the rendered page image + * @throws IllegalArgumentException if {@code dpi <= 0} + * @throws IndexOutOfBoundsException if {@code pageIndex} is out of range + * @throws DocumentRenderingException if rendering fails + * @since 1.9.0 + */ + public BufferedImage toImage(int pageIndex, int dpi) throws DocumentRenderingException { + return toImage(pageIndex, dpi, false); + } + + /** + * Renders a single page of the current document to a raster image at the given + * resolution. + * + * @param pageIndex zero-based page index + * @param dpi target resolution in dots per inch; must be {@code > 0} + * @param transparent {@code true} for a transparent (ARGB) background, {@code false} for opaque white (RGB) + * @return the rendered page image + * @throws IllegalArgumentException if {@code dpi <= 0} + * @throws IndexOutOfBoundsException if {@code pageIndex} is out of range + * @throws DocumentRenderingException if rendering fails + * @since 1.9.0 + */ + public BufferedImage toImage(int pageIndex, int dpi, boolean transparent) throws DocumentRenderingException { + requirePositiveDpi(dpi); + if (pageIndex < 0) { + throw new IndexOutOfBoundsException("pageIndex must be >= 0, got " + pageIndex); + } + return wrapImageRendering("render page " + pageIndex + " at " + dpi + " DPI", + () -> renderingFacade.renderImages(dpi, transparent, pageIndex).get(0)); + } + + private static void requirePositiveDpi(int dpi) { + if (dpi <= 0) { + throw new IllegalArgumentException("dpi must be > 0, got " + dpi); + } + } + /** * Closes measurement resources owned by the session. * @@ -914,7 +1014,7 @@ private void ensureOpen() { private void ensureRenderable() { if (roots.isEmpty()) { throw new IllegalStateException( - "Cannot render an empty document. Add at least one root before calling writePdf/toPdfBytes/buildPdf."); + "Cannot render an empty document. Add at least one root before calling writePdf/toPdfBytes/buildPdf/toImages/toImage."); } } @@ -954,6 +1054,11 @@ private interface PdfRenderingBody { R run() throws Exception; } + @FunctionalInterface + private interface ImageRenderingBody { + R run() throws Exception; + } + /** * Session-owned {@link NodeRegistry} subclass that funnels every * {@link #register(NodeDefinition)} call through both diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java index 309da1646..ee46170f4 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java @@ -16,9 +16,12 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.rendering.ImageType; +import org.apache.pdfbox.rendering.PDFRenderer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; @@ -285,7 +288,64 @@ public void write(LayoutGraph graph, FixedLayoutRenderContext context) throws Ex } private int renderToOutput(LayoutGraph graph, FixedLayoutRenderContext context, OutputStream output) throws Exception { - try (PDDocument document = new PDDocument()) { + try (PDDocument document = buildDocument(graph, context)) { + document.save(output); + return document.getNumberOfPages(); + } + } + + /** + * Renders the document to one rasterized image per page (or a single page + * when {@code pageIndex >= 0}). PDF-specific convenience that rasterizes the + * in-memory {@link PDDocument} directly via {@link PDFRenderer}, avoiding the + * serialize-to-bytes-then-reparse round-trip of {@link #render} + + * {@code Loader.loadPDF}. + * + * @param graph resolved layout graph + * @param context fixed-layout render configuration (output stream/file ignored) + * @param dpi target resolution in dots per inch (72 = native) + * @param transparent {@code true} for an ARGB image (transparent background), {@code false} for opaque RGB + * @param pageIndex zero-based page to render, or a negative value for all pages + * @return one image per rendered page, in page order + * @throws Exception if PDF creation, rendering, or rasterization fails + * @throws IndexOutOfBoundsException if {@code pageIndex} is out of range + */ + public List renderToImages(LayoutGraph graph, + FixedLayoutRenderContext context, + int dpi, + boolean transparent, + int pageIndex) throws Exception { + Objects.requireNonNull(graph, "graph"); + Objects.requireNonNull(context, "context"); + try (PDDocument document = buildDocument(graph, context)) { + PDFRenderer renderer = new PDFRenderer(document); + ImageType imageType = transparent ? ImageType.ARGB : ImageType.RGB; + int pageCount = document.getNumberOfPages(); + if (pageIndex >= 0) { + if (pageIndex >= pageCount) { + throw new IndexOutOfBoundsException( + "pageIndex " + pageIndex + " is out of bounds for " + pageCount + " page(s)"); + } + return List.of(renderer.renderImageWithDPI(pageIndex, (float) dpi, imageType)); + } + List images = new ArrayList<>(pageCount); + for (int page = 0; page < pageCount; page++) { + images.add(renderer.renderImageWithDPI(page, (float) dpi, imageType)); + } + return images; + } + } + + /** + * Builds the fully-rendered, post-processed {@link PDDocument} (pages drawn, + * links and bookmarks resolved, metadata / watermark / protection / + * header-footer applied) but does NOT save or close it — the caller owns the + * returned open document. On any build failure the document is closed and the + * exception rethrown, so the resource never leaks. + */ + private PDDocument buildDocument(LayoutGraph graph, FixedLayoutRenderContext context) throws Exception { + PDDocument document = new PDDocument(); + try { FontLibrary fonts = PdfFontLibraryFactory.library(document, context.customFontFamilies()); List pages = createPages(document, graph); @@ -326,8 +386,10 @@ private int renderToOutput(LayoutGraph graph, FixedLayoutRenderContext context, protectionOptions, headerFooterOptions); - document.save(output); - return pages.size(); + return document; + } catch (Exception ex) { + document.close(); + throw ex; } } diff --git a/src/main/java/com/demcha/compose/testing/visual/PdfVisualRegression.java b/src/main/java/com/demcha/compose/testing/visual/PdfVisualRegression.java index e3e2aa098..15cd9a577 100644 --- a/src/main/java/com/demcha/compose/testing/visual/PdfVisualRegression.java +++ b/src/main/java/com/demcha/compose/testing/visual/PdfVisualRegression.java @@ -1,5 +1,6 @@ package com.demcha.compose.testing.visual; +import com.demcha.compose.document.api.DocumentSession; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.rendering.ImageType; @@ -145,8 +146,26 @@ public PdfVisualRegression mismatchedPixelBudget(long mismatchedPixelBudget) { public void assertMatchesBaseline(String baselineName, byte[] pdfBytes) throws IOException { Objects.requireNonNull(baselineName, "baselineName"); Objects.requireNonNull(pdfBytes, "pdfBytes"); + assertMatchesBaseline(baselineName, renderPages(pdfBytes)); + } - List rendered = renderPages(pdfBytes); + /** + * Renders {@code session} page by page (directly, without an intermediate PDF + * byte array) and compares each page against the stored baseline. Throws an + * {@link AssertionError} when any page differs beyond the configured budget. + * + * @param baselineName baseline base name (no extension, no page suffix) + * @param session the document session to rasterize + * @throws IOException when reading or writing baseline files fails + * @since 1.9.0 + */ + public void assertMatchesBaseline(String baselineName, DocumentSession session) throws IOException { + Objects.requireNonNull(baselineName, "baselineName"); + Objects.requireNonNull(session, "session"); + assertMatchesBaseline(baselineName, renderPages(session)); + } + + private void assertMatchesBaseline(String baselineName, List rendered) throws IOException { Files.createDirectories(baselineRoot); if (approveMode()) { @@ -206,6 +225,25 @@ public List renderPages(byte[] pdfBytes) throws IOException { } } + /** + * Renders {@code session} directly into a list of one image per page, without + * the serialize-to-bytes-then-reparse round-trip of {@link #renderPages(byte[])}. + * The render scale is mapped to the {@link DocumentSession} DPI API as + * {@code round(scale * 72)}; for the default scale {@code 1.0} (and any scale + * that is a whole multiple of {@code 1/72}) this is pixel-identical to the + * byte-based path. For a fractional scale that does not divide evenly the DPI is + * rounded, so the two paths may differ by the rounding — keep {@code renderScale} + * an integer multiple of {@code 1/72} (e.g. 1.0, 2.0) when comparing the two. + * + * @param session the document session to rasterize + * @return list of page images at the configured render scale + * @since 1.9.0 + */ + public List renderPages(DocumentSession session) { + Objects.requireNonNull(session, "session"); + return session.toImages(Math.round(renderScale * 72f)); + } + private Path baselinePath(String baselineName, int pageIndex) { return baselineRoot.resolve(baselineName + "-page-" + pageIndex + ".png"); } diff --git a/src/test/java/com/demcha/compose/document/api/DocumentSessionImageTest.java b/src/test/java/com/demcha/compose/document/api/DocumentSessionImageTest.java new file mode 100644 index 000000000..d91d931ad --- /dev/null +++ b/src/test/java/com/demcha/compose/document/api/DocumentSessionImageTest.java @@ -0,0 +1,189 @@ +package com.demcha.compose.document.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.backend.fixed.pdf.options.PdfWatermarkOptions; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.testing.visual.ImageDiff; +import com.demcha.compose.testing.visual.PdfVisualRegression; + +import java.awt.image.BufferedImage; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Covers the direct render-to-image API ({@code toImages}/{@code toImage}) that + * rasterizes the in-memory document without the PDF byte round-trip. + */ +class DocumentSessionImageTest { + + private static DocumentSession singlePage() { + DocumentSession session = GraphCompose.document() + .pageSize(200, 140) + .margin(DocumentInsets.of(12)) + .create(); + session.compose(dsl -> dsl.pageFlow(flow -> flow.addText("Rendered straight to an image."))); + return session; + } + + private static DocumentSession multiPage() { + DocumentSession session = GraphCompose.document() + .pageSize(200, 140) + .margin(DocumentInsets.of(12)) + .create(); + session.compose(dsl -> dsl.pageFlow(flow -> { + for (int i = 0; i < 30; i++) { + flow.addText("Paragraph line number " + i + " of the multi-page render fixture."); + } + })); + return session; + } + + @Test + void toImagesReturnsOneOpaqueImagePerPage() { + try (DocumentSession session = multiPage()) { + int pages = session.layoutGraph().totalPages(); + assertThat(pages).isGreaterThanOrEqualTo(2); + + List images = session.toImages(72); + + assertThat(images).hasSize(pages); + for (BufferedImage image : images) { + assertThat(image).isNotNull(); + assertThat(image.getType()).isEqualTo(BufferedImage.TYPE_INT_RGB); + assertThat(image.getWidth()).isBetween(199, 201); // 200pt @ 72dpi + assertThat(image.getHeight()).isBetween(139, 141); // 140pt @ 72dpi + } + } + } + + @Test + void imageDimensionsScaleWithDpi() { + try (DocumentSession session = singlePage()) { + BufferedImage at72 = session.toImage(0, 72); + BufferedImage at144 = session.toImage(0, 144); + + assertThat(at72.getWidth()).isBetween(199, 201); + assertThat(at144.getWidth()).isBetween(399, 401); + assertThat(at144.getHeight()).isGreaterThan(at72.getHeight()); + } + } + + @Test + void renderedImageContainsPaintedContent() { + try (DocumentSession session = singlePage()) { + BufferedImage image = session.toImage(0, 144); + assertThat(hasNonWhitePixel(image)).as("text should paint non-background pixels").isTrue(); + } + } + + @Test + void transparentRendersArgbWithTransparentBackground() { + try (DocumentSession session = singlePage()) { + BufferedImage opaque = session.toImage(0, 96, false); + BufferedImage transparent = session.toImage(0, 96, true); + + assertThat(opaque.getType()).isEqualTo(BufferedImage.TYPE_INT_RGB); + assertThat(transparent.getType()).isEqualTo(BufferedImage.TYPE_INT_ARGB); + + // The top-left corner sits inside the 12pt page margin, so no content + // is painted there: opaque keeps a solid background, transparent stays + // fully see-through. + assertThat(alpha(opaque.getRGB(2, 2))).as("RGB background is opaque").isEqualTo(255); + assertThat(alpha(transparent.getRGB(2, 2))).as("ARGB empty area is fully transparent").isZero(); + } + } + + @Test + void toImageReturnsTheRequestedPage() { + try (DocumentSession session = multiPage()) { + List all = session.toImages(96); + assertThat(all).hasSizeGreaterThanOrEqualTo(2); + + BufferedImage page1 = session.toImage(1, 96); + + // The same page rendered both ways is pixel-identical... + assertThat(ImageDiff.compare(all.get(1), page1, 0).withinBudget(0)) + .as("toImage(1) matches toImages().get(1)").isTrue(); + // ...and a different page (distinct text) is not. + assertThat(ImageDiff.compare(all.get(0), page1, 0).withinBudget(0)) + .as("page 0 differs from page 1").isFalse(); + } + } + + @Test + void directRenderMatchesPdfByteRoundTrip() throws Exception { + try (DocumentSession session = multiPage()) { + PdfVisualRegression regression = PdfVisualRegression.standard(); + + List direct = regression.renderPages(session); + List roundTrip = regression.renderPages(session.toPdfBytes()); + + assertThat(direct).hasSameSizeAs(roundTrip); + for (int page = 0; page < direct.size(); page++) { + ImageDiff.Result diff = ImageDiff.compare(roundTrip.get(page), direct.get(page), 0); + assertThat(diff.withinBudget(0)).as("page %d is pixel-identical", page).isTrue(); + } + } + } + + @Test + void postProcessingDecorationsLandInTheRaster() { + // The watermark is applied by the post-processor inside the same build the + // rasterizer reads, so it must show up in the image (parity with the PDF). + try (DocumentSession plain = singlePage(); + DocumentSession watermarked = singlePage()) { + watermarked.watermark(PdfWatermarkOptions.builder().text("DRAFT").build()); + + BufferedImage plainImage = plain.toImage(0, 96); + BufferedImage watermarkedImage = watermarked.toImage(0, 96); + + assertThat(ImageDiff.compare(plainImage, watermarkedImage, 0).withinBudget(0)) + .as("watermark changes the rendered pixels").isFalse(); + } + } + + @Test + void nonPositiveDpiIsRejected() { + try (DocumentSession session = singlePage()) { + assertThatThrownBy(() -> session.toImages(0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> session.toImages(-5)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> session.toImage(0, 0)).isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void pageIndexOutOfRangeIsRejected() { + try (DocumentSession session = singlePage()) { + assertThatThrownBy(() -> session.toImage(-1, 72)).isInstanceOf(IndexOutOfBoundsException.class); + assertThatThrownBy(() -> session.toImage(5, 72)).isInstanceOf(IndexOutOfBoundsException.class); + } + } + + @Test + void renderingAnEmptyDocumentThrowsIllegalState() { + try (DocumentSession session = GraphCompose.document().pageSize(200, 140).create()) { + assertThatThrownBy(() -> session.toImages(72)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("empty document"); + } + } + + private static int alpha(int argb) { + return (argb >>> 24) & 0xFF; + } + + private static boolean hasNonWhitePixel(BufferedImage image) { + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + if ((image.getRGB(x, y) & 0xFFFFFF) != 0xFFFFFF) { + return true; + } + } + } + return false; + } +} From 36f4d9fc6624bb7a5c9c24f9a97a4177092f716e Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 22 Jun 2026 22:56:28 +0100 Subject: [PATCH 2/2] Add GraphCompose Logo svg --- assets/readme/GraphComposeLogo_final.svg | 60 ++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 assets/readme/GraphComposeLogo_final.svg diff --git a/assets/readme/GraphComposeLogo_final.svg b/assets/readme/GraphComposeLogo_final.svg new file mode 100644 index 000000000..2611e0501 --- /dev/null +++ b/assets/readme/GraphComposeLogo_final.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +