diff --git a/CHANGELOG.md b/CHANGELOG.md index c12c0fd0..8a76c794 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A failed render no longer destroys the document it was replacing.** + `buildPdf(Path)`, `buildPptx(Path)` and multi-section `buildPdf(Path)` render into + a scratch file in the destination's own directory and move it onto the destination + only after the render returns. Previously the destination was opened — and therefore + truncated — before the backend produced a byte, so an oversized node, a missing + backend, or a full disk left an empty file where a published document used to be. + The move is atomic where the filesystem supports it, so a concurrent reader never + observes a half-written document; on POSIX the destination keeps the permissions it + already had, or gets `rw-r--r--` when it is new. +- **`DocumentSession.buildPptx()` (no-arg) is removed**; use `buildPptx(Path)`. The + session has a single configured output path, shared with `buildPdf()`, so the no-arg + form wrote deck bytes into whatever that path was — including a file named `.pdf`. + PPTX output now always names its destination, which is also what lets one session + emit both formats. The PPTX surface is `@Beta` (Experimental) and was never published, + so no released code can depend on the removed overload. +- **Two backends registered for one format now fail loudly.** + `BackendProviders.fixedLayout(format)` and the no-arg default previously took the + first `ServiceLoader` match, so a third-party provider declaring an existing format + silently decided the renderer by classpath order. Both entry points now throw + `IllegalStateException` naming the competing provider classes. - **Keep a heading with its content** — `SectionBuilder.keepWithNext()`. A section marked keep-with-next is never left stranded as the last block on a page apart from the content it introduces: when the section plus the first slice of the following @@ -184,7 +204,7 @@ follow semantic versioning; release dates are ISO 8601. documents that custom handlers do not apply inside rasterized clip composites. - `DocumentSession.toPptxBytes()` / `writePptx(OutputStream)` / - `buildPptx()` / `buildPptx(Path)` — the PPTX counterparts of the PDF + `buildPptx(Path)` / `buildPptx(Path)` — the PPTX counterparts of the PDF convenience trio. The backend resolves through the format-keyed provider (`BackendProviders.fixedLayout("pptx")`), so the core stays free of a PPTX dependency: with `graph-compose-render-pptx` on the classpath the session's @@ -243,7 +263,7 @@ follow semantic versioning; release dates are ISO 8601. AUTO-PAGINATION` tags and a `code → layout → PDF/PPTX` diagram whose amber connectors branch to both backends — composed as one full-bleed `CanvasLayerNode` and emitted as an editable PowerPoint slide via - `buildPptx()`. Registered in `GenerateAllExamples` and listed in the + `buildPptx(Path)`. Registered in `GenerateAllExamples` and listed in the examples gallery with committed PDF/PPTX previews; a native-shape guard (`MavenBannerNativeShapeTest`) pins the slide to a single picture (the badge checkmark) with the panels, tags, code and diagram all native. diff --git a/core/src/main/java/com/demcha/compose/GraphCompose.java b/core/src/main/java/com/demcha/compose/GraphCompose.java index ebb68d74..7beb842d 100644 --- a/core/src/main/java/com/demcha/compose/GraphCompose.java +++ b/core/src/main/java/com/demcha/compose/GraphCompose.java @@ -85,9 +85,11 @@ public static DocumentBuilder document() { /** * Starts the canonical semantic document composition flow with a default output target - * used by {@link DocumentSession#buildPdf()} and {@link DocumentSession#buildPptx()}. + * used by {@link DocumentSession#buildPdf()}. PPTX output always takes an explicit + * path — see {@link DocumentSession#buildPptx(Path)} — so one session can emit both + * formats without the deck overwriting the PDF. * - * @param outputFile default output path for the no-arg build methods + * @param outputFile default output path for {@code buildPdf()} * @return builder for creating a semantic document session */ public static DocumentBuilder document(Path outputFile) { diff --git a/core/src/main/java/com/demcha/compose/document/api/AtomicFileOutput.java b/core/src/main/java/com/demcha/compose/document/api/AtomicFileOutput.java new file mode 100644 index 00000000..88a6c448 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/api/AtomicFileOutput.java @@ -0,0 +1,144 @@ +package com.demcha.compose.document.api; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Set; + +/** + * Writes a rendered document to a file without destroying the previous + * contents when the render fails. + * + *
Rendering straight into the destination truncates it the moment the + * stream opens, long before the backend has produced a single byte. A render + * that then fails — a missing backend, an unsupported payload, a full disk — + * leaves the caller with an empty or half-written file where a good document + * used to be. That matters most for the case this library is built for: a + * server that overwrites a previously published document in place.
+ * + *So the bytes go to a temporary file in the destination's own directory + * and are moved onto the destination only after the writer returns normally. + * The move is atomic where the filesystem supports it, which also means a + * concurrent reader never observes a partially written document. On failure + * the temporary file is removed and the destination is left untouched.
+ * + *Permissions. {@link Files#createTempFile} deliberately creates an + * owner-only file, and {@link Files#move} carries those permissions onto the + * destination — which would silently narrow a served file from world-readable + * to owner-only. On POSIX filesystems the temporary file is therefore widened + * before the move: to the permissions the destination already had when it + * exists, and to {@code rw-r--r--} when it does not. Filesystems without POSIX + * support (Windows) are unaffected.
+ * + *Symbolic links. Publishing replaces the destination entry. + * When the destination is a symlink, the link itself is replaced by the rendered + * file rather than the render being written through it to the link's target. + * Render to the real path when a symlink must survive.
+ * + * @author Artem Demchyshyn + */ +final class AtomicFileOutput { + + private static final Logger LOG = LoggerFactory.getLogger("com.demcha.compose.document.lifecycle"); + + private static final SetThread-safety: this type is mutable and not thread-safe.
@@ -989,32 +989,6 @@ public void writePptx(OutputStream output) throws DocumentRenderingException { }); } - /** - * Builds the current document as a .pptx deck into the default output file - * configured on the builder. See {@link #toPptxBytes()} for the geometry - * and classpath contract. - * - *The default file is shared with {@link #buildPdf()}: the session has - * one configured output path, and this method writes deck bytes to it - * as-is — pass an explicit {@code .pptx} path to - * {@link #buildPptx(Path)} when the default is named for PDF output.
- * - * @throws IllegalStateException if no default output file was configured - * @throws DocumentRenderingException if PPTX rendering fails - * @since 2.1.0 - */ - @Beta - public void buildPptx() throws DocumentRenderingException { - ensureOpen(); - if (defaultOutputFile == null) { - throw new IllegalStateException("No default output file was configured for this document session."); - } - wrapRendering("build PPTX at '" + defaultOutputFile + "'", () -> { - renderingFacade.buildPptx(defaultOutputFile); - return null; - }); - } - /** * Builds the current document as a .pptx deck into the supplied output * file. See {@link #toPptxBytes()} for the geometry and classpath contract. diff --git a/core/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java b/core/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java index 8f6b9b31..5ffd524e 100644 --- a/core/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java +++ b/core/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java @@ -140,9 +140,7 @@ public void buildPdf() throws DocumentRenderingException { public void buildPdf(Path outputFile) throws DocumentRenderingException { Objects.requireNonNull(outputFile, "outputFile"); render("build PDF at '" + outputFile + "'", () -> { - try (OutputStream output = Files.newOutputStream(outputFile)) { - backend.writeSections(renderUnits(), output); - } + AtomicFileOutput.write(outputFile, output -> backend.writeSections(renderUnits(), output)); return null; }); } diff --git a/core/src/main/java/com/demcha/compose/document/backend/fixed/BackendProviders.java b/core/src/main/java/com/demcha/compose/document/backend/fixed/BackendProviders.java index 3bc72016..0b6db3e6 100644 --- a/core/src/main/java/com/demcha/compose/document/backend/fixed/BackendProviders.java +++ b/core/src/main/java/com/demcha/compose/document/backend/fixed/BackendProviders.java @@ -11,6 +11,7 @@ import java.util.ServiceLoader; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; +import java.util.stream.Collectors; /** * Locates the fixed-layout backend service providers registered on the @@ -76,13 +77,16 @@ public static FixedLayoutBackendProvider fixedLayout() { * format, resolving and caching it on first use. * *Matching against {@link FixedLayoutBackendProvider#format()} is - * case-insensitive. When several providers declare the same format the - * first one enumerated by {@link ServiceLoader} wins.
+ * case-insensitive. Two providers claiming the same format is a classpath + * mistake rather than a preference, so it fails loudly instead of letting + * {@link ServiceLoader} enumeration order silently decide which backend + * renders the document. * * @param format output format identifier such as {@code "pdf"} or {@code "pptx"} * @return the provider rendering that format * @throws MissingBackendException if no provider for the format is registered * on the classpath + * @throws IllegalStateException if more than one provider declares the format * @since 2.1.0 */ public static FixedLayoutBackendProvider fixedLayout(String format) { @@ -92,11 +96,31 @@ public static FixedLayoutBackendProvider fixedLayout(String format) { if (cached != null) { return cached; } - FixedLayoutBackendProvider resolved = fixedLayoutProviders().stream() + return cacheByFormat(key, resolveExactlyOne(key)); + } + + /** + * Resolves the single provider declaring {@code key}, refusing an ambiguous + * classpath. Shared by the by-format lookup and the default lookup so both + * entry points agree about what "registered" means. + */ + private static FixedLayoutBackendProvider resolveExactlyOne(String key) { + ListRendering used to open the destination directly, which truncates it before + * the backend has produced a byte — so a mid-render failure replaced a good + * published document with an empty file. These tests pin the contract of the + * {@link AtomicFileOutput} helper in isolation; the session-level path that a + * caller actually reaches is covered by {@code DocumentOutputFileSafetyTest} in + * the qa module, which needs a real backend to get as far as rendering.
+ */ +class FileOutputSafetyTest { + + private static final String SENTINEL = "previously published document"; + + @Test + void aSuccessfulWriteReplacesTheDestinationAndRemovesTheScratchFile(@TempDir Path tempDir) throws Exception { + Path published = tempDir.resolve("report.bin"); + Files.writeString(published, SENTINEL); + + AtomicFileOutput.write(published, output -> output.write("fresh".getBytes(StandardCharsets.UTF_8))); + + assertThat(Files.readString(published)).isEqualTo("fresh"); + assertThat(listFiles(tempDir)).containsExactly(published); + } + + @Test + void aWriterFailingMidStreamPublishesNothing(@TempDir Path tempDir) throws Exception { + Path published = tempDir.resolve("report.bin"); + Files.writeString(published, SENTINEL); + + assertThatThrownBy(() -> AtomicFileOutput.write(published, output -> { + output.write("partial".getBytes(StandardCharsets.UTF_8)); + throw new IllegalStateException("backend blew up halfway"); + })) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("halfway"); + + assertThat(Files.readString(published)) + .describedAs("half-written bytes must never reach the destination") + .isEqualTo(SENTINEL); + assertThat(listFiles(tempDir)).containsExactly(published); + } + + @Test + void writingCreatesTheDestinationWhenItDoesNotExistYet(@TempDir Path tempDir) throws Exception { + Path published = tempDir.resolve("new-report.bin"); + + AtomicFileOutput.write(published, output -> output.write("fresh".getBytes(StandardCharsets.UTF_8))); + + assertThat(Files.readString(published)).isEqualTo("fresh"); + } + + @Test + void publishingKeepsThePermissionsTheDestinationAlreadyHad(@TempDir Path tempDir) throws Exception { + assumePosix(tempDir); + Path published = tempDir.resolve("report.bin"); + Files.writeString(published, SENTINEL); + SetThe format is deliberately not {@code "aaa"} or {@code "zzz"}: those drive + * {@link BackendProvidersSelectionTest}, and {@code "aaa"} sorts before + * {@code "dup"}, so the default-selection contract there is unaffected.
+ */ +final class DuplicateFormatFakeProviders { + + static final String FORMAT = "dup"; + + private DuplicateFormatFakeProviders() { + } + + /** First registered provider for {@link #FORMAT}. */ + public static final class One implements FixedLayoutBackendProvider { + + @Override + public String format() { + return FORMAT; + } + + @Override + public FixedLayoutRenderer create(DocumentOutputOptions chrome, DocumentDebugOptions debug) { + throw new UnsupportedOperationException("selection-test fake; never renders"); + } + } + + /** Second registered provider for {@link #FORMAT}. */ + public static final class Two implements FixedLayoutBackendProvider { + + @Override + public String format() { + return FORMAT; + } + + @Override + public FixedLayoutRenderer create(DocumentOutputOptions chrome, DocumentDebugOptions debug) { + throw new UnsupportedOperationException("selection-test fake; never renders"); + } + } +} diff --git a/core/src/test/resources/META-INF/services/com.demcha.compose.document.backend.fixed.FixedLayoutBackendProvider b/core/src/test/resources/META-INF/services/com.demcha.compose.document.backend.fixed.FixedLayoutBackendProvider index 733c62e3..72bc0f7b 100644 --- a/core/src/test/resources/META-INF/services/com.demcha.compose.document.backend.fixed.FixedLayoutBackendProvider +++ b/core/src/test/resources/META-INF/services/com.demcha.compose.document.backend.fixed.FixedLayoutBackendProvider @@ -1,2 +1,4 @@ com.demcha.compose.document.backend.fixed.ZuluFakeFixedLayoutBackendProvider com.demcha.compose.document.backend.fixed.AlphaFakeFixedLayoutBackendProvider +com.demcha.compose.document.backend.fixed.DuplicateFormatFakeProviders$One +com.demcha.compose.document.backend.fixed.DuplicateFormatFakeProviders$Two diff --git a/examples/README.md b/examples/README.md index 5b95962f..89f87e4b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -61,9 +61,9 @@ are with the canonical DSL, then jump to its detailed section below. | [Cover Letter](#cover-letter) | One-page `BusinessTheme.modern()` cover letter with section presets | [PDF](../assets/readme/examples/cover-letter.pdf) · [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java) | | [Module-first Profile](#module-first-profile) | Authoring directly against `DocumentSession.module(...).paragraph(...)` — DSL-direct, no template | [PDF](../assets/readme/examples/module-first-profile.pdf) · [Source](src/main/java/com/demcha/examples/flagships/ModuleFirstFileExample.java) | | **Engine Showcase** | Single-page cinematic brand promo — semantic-graph → polished-PDFs visual metaphor with rounded clip frame, magazine headline lockup, KPI cards, capability columns; source of the README hero image | [Source](src/main/java/com/demcha/examples/flagships/EngineShowcase.java) | -| **Engine Deck** | Multi-page **landscape** capability deck — page 1 is a banner infographic (DSL code → engine → backends → **real rendered-document thumbnails**), then an authoring-pipeline walkthrough, and two pages of **real benchmark data** (GraphCompose vs iText 9 vs JasperReports) loaded from a bundled result file and drawn as tables + native charts; the landscape companion to Engine Showcase. The same composition also renders as a **geometry-identical PowerPoint deck** (one page = one editable slide) through `buildPptx()` | [PDF](../assets/readme/examples/engine-deck.pdf) · [Source](src/main/java/com/demcha/examples/flagships/EngineDeckExample.java) · [PPTX source](src/main/java/com/demcha/examples/flagships/EngineDeckPptxExample.java) | +| **Engine Deck** | Multi-page **landscape** capability deck — page 1 is a banner infographic (DSL code → engine → backends → **real rendered-document thumbnails**), then an authoring-pipeline walkthrough, and two pages of **real benchmark data** (GraphCompose vs iText 9 vs JasperReports) loaded from a bundled result file and drawn as tables + native charts; the landscape companion to Engine Showcase. The same composition also renders as a **geometry-identical PowerPoint deck** (one page = one editable slide) through `buildPptx(Path)` | [PDF](../assets/readme/examples/engine-deck.pdf) · [Source](src/main/java/com/demcha/examples/flagships/EngineDeckExample.java) · [PPTX source](src/main/java/com/demcha/examples/flagships/EngineDeckPptxExample.java) | | **Twin Output** | The dual-output hook stated by the artifact itself — a single 16:9 page written once and emitted **twice from the same session**: `buildPdf()` and `buildPptx(...)` produce a print-ready PDF and a PowerPoint slide with identical geometry where text, panels, and vectors stay native, editable shapes (only the clip-masked logo art lands as a picture); the root README shows PowerPoint's own render of the slide next to the PDF | [PDF](../assets/readme/examples/twin-output.pdf) · [PPTX](../assets/readme/examples/twin-output.pptx) · [Source](src/main/java/com/demcha/examples/flagships/TwinOutputExample.java) | -| **Maven Central Banner** | Single 16:9 "Available on Maven Central" brand slide — the `GraphCompose` wordmark, an `io.github.demchaav:graph-compose` coordinate card, `JAVA 17+ / PDF / PPTX / AUTO-PAGINATION` tags, and a `code → layout → PDF/PPTX` diagram whose amber connectors branch to both backends — composed as one full-bleed `CanvasLayerNode` and emitted as an editable PowerPoint slide via `buildPptx()`; panels and text stay native shapes, only the badge checkmark rasterises | [PDF](../assets/readme/examples/maven-banner.pdf) · [PPTX](../assets/readme/examples/maven-banner.pptx) · [Source](src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java) | +| **Maven Central Banner** | Single 16:9 "Available on Maven Central" brand slide — the `GraphCompose` wordmark, an `io.github.demchaav:graph-compose` coordinate card, `JAVA 17+ / PDF / PPTX / AUTO-PAGINATION` tags, and a `code → layout → PDF/PPTX` diagram whose amber connectors branch to both backends — composed as one full-bleed `CanvasLayerNode` and emitted as an editable PowerPoint slide via `buildPptx(Path)`; panels and text stay native shapes, only the badge checkmark rasterises | [PDF](../assets/readme/examples/maven-banner.pdf) · [PPTX](../assets/readme/examples/maven-banner.pptx) · [Source](src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java) | ### 🧱 Core DSL diff --git a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckPptxExample.java b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckPptxExample.java index 6cdb64d6..7eb7b66c 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckPptxExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckPptxExample.java @@ -11,7 +11,7 @@ * The Engine Deck flagship rendered as a PowerPoint deck: the exact * composition of {@link EngineDeckExample} — banner infographic, authoring * pipeline, and two benchmark pages — built once and emitted through the - * fixed-layout PPTX backend via {@link DocumentSession#buildPptx()}. One + * fixed-layout PPTX backend via {@link DocumentSession#buildPptx(java.nio.file.Path)}. One * resolved page becomes one identically-sized slide with the same geometry * the PDF carries, so the .pptx opens in PowerPoint as a slide-per-page copy * of the PDF deck — editable shapes and text frames, except clipped regions, @@ -35,7 +35,7 @@ public static Path generate() throws Exception { .margin(16, 16, 30, 16) .create()) { EngineDeckExample.compose(document); - document.buildPptx(); + document.buildPptx(outputFile); } return outputFile; } diff --git a/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java b/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java index 41dbf9d4..44b7be5e 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java @@ -32,7 +32,7 @@ /** * Single-slide "Available on Maven Central" brand banner, composed with the * canonical document DSL and emitted through the fixed-layout PPTX backend via - * {@link DocumentSession#buildPptx()}. One resolved 16:9 page becomes one + * {@link DocumentSession#buildPptx(java.nio.file.Path)}. One resolved 16:9 page becomes one * identically-sized slide carrying the same geometry, so the {@code .pptx} * opens in PowerPoint as an editable copy of the banner — gradient, rounded * panels, native paths and text frames. @@ -99,7 +99,7 @@ public static Path generate() throws Exception { .margin(DocumentInsets.zero()) .create()) { compose(document); - document.buildPptx(); + document.buildPptx(outputFile); } return outputFile; } diff --git a/qa/src/test/java/com/demcha/compose/document/api/DocumentOutputFileSafetyTest.java b/qa/src/test/java/com/demcha/compose/document/api/DocumentOutputFileSafetyTest.java new file mode 100644 index 00000000..c7b84ed2 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/DocumentOutputFileSafetyTest.java @@ -0,0 +1,115 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.exceptions.AtomicNodeTooLargeException; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.node.ImageNode; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import javax.imageio.ImageIO; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The session-level half of the non-destructive output contract: a render that + * fails must leave the document already at that path exactly as it was. + * + *The failure is triggered the way a real one arrives — an atomic node taller + * than the page, which fails while the layout graph is being compiled, i.e. + * after the destination would have been opened by the old implementation and + * before a single output byte exists. Anything published to a server and then + * re-rendered with bad input hits this exact window.
+ * + *Lives in qa rather than core because reaching the render path at all needs + * a font-metrics provider, which the lean core has no backend to supply.
+ */ +class DocumentOutputFileSafetyTest { + + private static final String SENTINEL = "previously published document"; + + @Test + void aFailedRenderLeavesTheExistingDocumentUntouched(@TempDir Path tempDir) throws Exception { + Path published = tempDir.resolve("report.pdf"); + Files.writeString(published, SENTINEL); + + try (DocumentSession session = oversizedDocument()) { + assertThatThrownBy(() -> session.buildPdf(published)) + .isInstanceOf(AtomicNodeTooLargeException.class); + } + + assertThat(Files.readString(published)) + .describedAs("a failed render must not truncate the document it was replacing") + .isEqualTo(SENTINEL); + } + + @Test + void aFailedRenderLeavesNoScratchFileBehind(@TempDir Path tempDir) throws Exception { + Path published = tempDir.resolve("report.pdf"); + Files.writeString(published, SENTINEL); + + try (DocumentSession session = oversizedDocument()) { + assertThatThrownBy(() -> session.buildPdf(published)) + .isInstanceOf(AtomicNodeTooLargeException.class); + } + + assertThat(listFiles(tempDir)) + .describedAs("the scratch file must be removed when the render fails") + .containsExactly(published); + } + + @Test + void aSuccessfulRenderReplacesTheDocumentAndLeavesNoScratchFile(@TempDir Path tempDir) throws Exception { + Path published = tempDir.resolve("report.pdf"); + Files.writeString(published, SENTINEL); + + try (DocumentSession session = GraphCompose.document() + .pageSize(200, 200) + .margin(DocumentInsets.of(12)) + .create()) { + session.pageFlow(page -> page.module("m", module -> module.paragraph("hello"))); + session.buildPdf(published); + } + + assertThat(Files.readAllBytes(published)).startsWith('%', 'P', 'D', 'F'); + assertThat(listFiles(tempDir)).containsExactly(published); + } + + /** A document whose only node is taller than the page can ever be. */ + private static DocumentSession oversizedDocument() throws Exception { + DocumentSession session = GraphCompose.document() + .pageSize(180, 180) + .margin(DocumentInsets.of(12)) + .create(); + session.add(new ImageNode( + "TooTallImage", + DocumentImageData.fromBytes(onePixelPng()), + 96.0, + 240.0, + DocumentInsets.zero(), + DocumentInsets.zero())); + return session; + } + + private static byte[] onePixelPng() throws IOException { + BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ImageIO.write(image, "png", bytes); + return bytes.toByteArray(); + } + + private static List