From ddf03f89870aafd9283ecfd704b0c944081892fd Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 25 Jul 2026 08:59:52 +0100
Subject: [PATCH] fix(output): make file rendering non-destructive and the PPTX
target explicit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rendering to a file opened the destination before the backend produced a byte,
so the file was truncated first and written second. A render that then failed —
an atomic node taller than the page, a missing backend, a full disk — replaced a
published document with an empty one. That is the exact shape of the library's
main use case: a server re-rendering a document that is already being served.
Route both file paths through a scratch file in the destination's own directory,
moved onto the destination only after the render returns normally. The move is
atomic where the filesystem supports it, so a concurrent reader never sees a
half-written document, and the scratch file is discarded on failure without
masking the render's own exception. Two details the naive form gets wrong are
handled explicitly: a temp file is created owner-only and `Files.move` carries
that mode onto the destination, which would silently narrow a served file, so
POSIX permissions are aligned to the destination's existing mode (or rw-r--r--
for a new file) before the move; and replacing the destination entry replaces a
symlink rather than writing through it, which the contract now states.
Remove the no-arg `DocumentSession.buildPptx()`. The session holds one configured
output path, shared with `buildPdf()`, so the no-arg form wrote deck bytes into
whatever that path was — its own Javadoc had to warn that a `.pdf` default would
receive a PPTX. Naming the destination is also what lets one session emit both
formats. The surface is `@Beta` and unpublished, so nothing released can depend
on it; `graph-compose-core` at the 2.0.0 baseline does not declare the method at
all, which is why the binary-compatibility gate stays green.
Make duplicate backend registrations fail instead of resolving by classpath
order: a third-party provider declaring an already-registered format silently
decided which backend rendered the document. Both the by-format lookup and the
no-arg default now share one resolver that throws `IllegalStateException` naming
the competing classes.
Tests: the qa regression writes a sentinel document, fails a render on an
oversized node, and asserts the sentinel survives — it fails on the previous
implementation with `expected: "previously published document" but was: ""`.
Helper-level tests cover mid-stream failure, scratch cleanup, first-time
creation, a missing parent directory, and the permission alignment (skipped off
POSIX). Two ServiceLoader fakes sharing one format cover the ambiguity, with a
companion test proving unrelated formats still resolve.
Verified: full reactor `clean verify` green (1518 tests, 0 failures, 2 skipped
off POSIX); japicmp against the 2.0.0 baseline green; `javadoc:javadoc` green on
the six published modules and on examples, where two `{@link}` references to the
removed overload lived.
---
CHANGELOG.md | 24 ++-
.../java/com/demcha/compose/GraphCompose.java | 6 +-
.../document/api/AtomicFileOutput.java | 144 ++++++++++++++++++
.../document/api/DocumentRenderingFacade.java | 4 +-
.../compose/document/api/DocumentSession.java | 28 +---
.../document/api/MultiSectionDocument.java | 4 +-
.../backend/fixed/BackendProviders.java | 41 ++++-
.../exceptions/MissingBackendException.java | 2 +-
.../document/api/FileOutputSafetyTest.java | 119 +++++++++++++++
.../fixed/BackendProvidersSelectionTest.java | 17 +++
.../fixed/DuplicateFormatFakeProviders.java | 50 ++++++
...t.backend.fixed.FixedLayoutBackendProvider | 2 +
examples/README.md | 4 +-
.../flagships/EngineDeckPptxExample.java | 4 +-
.../flagships/MavenBannerPptxExample.java | 4 +-
.../api/DocumentOutputFileSafetyTest.java | 115 ++++++++++++++
.../pptx/PptxSessionConvenienceTest.java | 24 ++-
17 files changed, 528 insertions(+), 64 deletions(-)
create mode 100644 core/src/main/java/com/demcha/compose/document/api/AtomicFileOutput.java
create mode 100644 core/src/test/java/com/demcha/compose/document/api/FileOutputSafetyTest.java
create mode 100644 core/src/test/java/com/demcha/compose/document/backend/fixed/DuplicateFormatFakeProviders.java
create mode 100644 qa/src/test/java/com/demcha/compose/document/api/DocumentOutputFileSafetyTest.java
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 Set DEFAULT_PERMISSIONS =
+ Set.copyOf(PosixFilePermissions.fromString("rw-r--r--"));
+
+ private AtomicFileOutput() {
+ }
+
+ /**
+ * Writes bytes into a caller-owned stream.
+ */
+ @FunctionalInterface
+ interface StreamWriter {
+ /**
+ * @param output stream to write to; closed by the caller, not the writer
+ * @throws Exception if rendering fails
+ */
+ void writeTo(OutputStream output) throws Exception;
+ }
+
+ /**
+ * Renders through {@code writer} and publishes the result at {@code target}.
+ *
+ * @param target destination file, replaced only after a successful render
+ * @param writer produces the document bytes
+ * @throws NoSuchFileException if the destination's parent directory does not exist
+ * @throws Exception whatever the writer throws, with {@code target} untouched
+ */
+ static void write(Path target, StreamWriter writer) throws Exception {
+ Path directory = target.toAbsolutePath().getParent();
+ if (directory == null || !Files.isDirectory(directory)) {
+ throw new NoSuchFileException(
+ target.toString(),
+ null,
+ "The parent directory does not exist. Create it before rendering.");
+ }
+
+ Path temporary = Files.createTempFile(directory, ".graphcompose-", ".tmp");
+ boolean published = false;
+ try {
+ try (OutputStream output = Files.newOutputStream(temporary)) {
+ writer.writeTo(output);
+ }
+ alignPermissions(temporary, target);
+ move(temporary, target);
+ published = true;
+ } finally {
+ if (!published) {
+ discard(temporary);
+ }
+ }
+ }
+
+ /**
+ * Removes the scratch file without masking the failure that caused it to be
+ * abandoned. A cleanup {@link IOException} — a scanner still holding the
+ * handle, a revoked mount — must not replace the render diagnostic the
+ * caller actually needs.
+ */
+ private static void discard(Path temporary) {
+ try {
+ Files.deleteIfExists(temporary);
+ } catch (IOException ex) {
+ LOG.debug("document.output.scratch-cleanup-failed path={}", temporary, ex);
+ }
+ }
+
+ private static void move(Path temporary, Path target) throws IOException {
+ try {
+ Files.move(temporary, target,
+ StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (AtomicMoveNotSupportedException ex) {
+ // Some network and FUSE filesystems reject ATOMIC_MOVE. Replacing
+ // without the atomicity guarantee is still strictly better than
+ // truncating the destination up front.
+ Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ private static void alignPermissions(Path temporary, Path target) {
+ if (!temporary.getFileSystem().supportedFileAttributeViews().contains("posix")) {
+ return;
+ }
+ try {
+ Set permissions = Files.exists(target)
+ ? Files.getPosixFilePermissions(target)
+ : DEFAULT_PERMISSIONS;
+ Files.setPosixFilePermissions(temporary, permissions);
+ } catch (IOException | UnsupportedOperationException ex) {
+ // A render that succeeded must not fail over file metadata; the
+ // document is still published, just with the temp file's mode.
+ }
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java b/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java
index 97cb083f..a891377e 100644
--- a/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java
+++ b/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java
@@ -189,8 +189,8 @@ private void buildFixedLayout(String format, Path outputFile) throws Exception {
long startNanos = System.nanoTime();
LIFECYCLE_LOG.debug("document.{}.build.start sessionId={} revision={} roots={}",
format, context.sessionId(), context.revision(), context.rootCount());
- try (OutputStream output = Files.newOutputStream(target)) {
- writeFixedLayout(format, output);
+ try {
+ AtomicFileOutput.write(target, output -> writeFixedLayout(format, output));
LIFECYCLE_LOG.debug(
"document.{}.build.end sessionId={} revision={} durationMs={}",
format,
diff --git a/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java
index 281f3c2d..1cb74ec1 100644
--- a/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java
+++ b/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java
@@ -54,7 +54,7 @@
* inspect {@link #layoutGraph()} / {@link #layoutSnapshot()} as needed
* render with {@link #writePdf(OutputStream)}, {@link #toPdfBytes()}, {@link #buildPdf()},
* their PPTX counterparts ({@link #writePptx(OutputStream)}, {@link #toPptxBytes()},
- * {@link #buildPptx()}), or a custom backend
+ * {@link #buildPptx(Path)}), or a custom backend
*
*
* Thread-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) {
+ List matches = fixedLayoutProviders().stream()
.filter(provider -> key.equals(normalizedFormat(provider)))
- .findFirst()
- .orElseThrow(() -> new MissingBackendException(missingFormatMessage(key)));
- return cacheByFormat(key, resolved);
+ .toList();
+ if (matches.isEmpty()) {
+ throw new MissingBackendException(missingFormatMessage(key));
+ }
+ if (matches.size() > 1) {
+ String names = matches.stream()
+ .map(provider -> provider.getClass().getName())
+ .collect(Collectors.joining(", "));
+ throw new IllegalStateException(
+ "Multiple fixed-layout backends are registered for format \"" + key + "\": " + names
+ + ". Remove the duplicate artifact from the classpath, or select a backend "
+ + "explicitly instead of resolving it by format.");
+ }
+ return matches.get(0);
}
/**
@@ -122,7 +146,10 @@ private static FixedLayoutBackendProvider defaultFixedLayout() {
!DEFAULT_FIXED_LAYOUT_FORMAT.equals(normalizedFormat(provider)))
.thenComparing(BackendProviders::normalizedFormat))
.orElseThrow(missingBackend());
- return cacheByFormat(normalizedFormat(chosen), chosen);
+ // The comparator picks a format deterministically, but two providers
+ // claiming that format would still make the winner arbitrary.
+ String key = normalizedFormat(chosen);
+ return cacheByFormat(key, resolveExactlyOne(key));
}
/**
diff --git a/core/src/main/java/com/demcha/compose/document/exceptions/MissingBackendException.java b/core/src/main/java/com/demcha/compose/document/exceptions/MissingBackendException.java
index b56d2e18..bf5bf4b8 100644
--- a/core/src/main/java/com/demcha/compose/document/exceptions/MissingBackendException.java
+++ b/core/src/main/java/com/demcha/compose/document/exceptions/MissingBackendException.java
@@ -10,7 +10,7 @@
* {@code io.github.demchaav:graph-compose-render-pptx}) discovered at
* runtime through {@link java.util.ServiceLoader}. Calling a convenience output
* method — {@code toPdfBytes()}, {@code buildPdf()}, {@code toImages()},
- * {@code toPptxBytes()}, {@code buildPptx()} — or requesting
+ * {@code toPptxBytes()}, {@code buildPptx(Path)} — or requesting
* {@code layoutSnapshot()} without the matching artifact on the classpath
* fails with this exception. The message names the artifact to add.
*
diff --git a/core/src/test/java/com/demcha/compose/document/api/FileOutputSafetyTest.java b/core/src/test/java/com/demcha/compose/document/api/FileOutputSafetyTest.java
new file mode 100644
index 00000000..495272f3
--- /dev/null
+++ b/core/src/test/java/com/demcha/compose/document/api/FileOutputSafetyTest.java
@@ -0,0 +1,119 @@
+package com.demcha.compose.document.api;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * A render that fails must not destroy the document that was already there.
+ *
+ * Rendering 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);
+ Set readableByAll = PosixFilePermissions.fromString("rw-r--r--");
+ Files.setPosixFilePermissions(published, readableByAll);
+
+ AtomicFileOutput.write(published, output -> output.write("fresh".getBytes(StandardCharsets.UTF_8)));
+
+ assertThat(Files.getPosixFilePermissions(published))
+ .describedAs("re-rendering a served document must not narrow it to owner-only")
+ .isEqualTo(readableByAll);
+ }
+
+ @Test
+ void aBrandNewDocumentIsNotPublishedAsOwnerOnly(@TempDir Path tempDir) throws Exception {
+ assumePosix(tempDir);
+ Path published = tempDir.resolve("new-report.bin");
+
+ AtomicFileOutput.write(published, output -> output.write("fresh".getBytes(StandardCharsets.UTF_8)));
+
+ assertThat(Files.getPosixFilePermissions(published))
+ .describedAs("the scratch file's owner-only mode must not leak onto the destination")
+ .contains(PosixFilePermission.OTHERS_READ);
+ }
+
+ private static void assumePosix(Path directory) {
+ assumeTrue(directory.getFileSystem().supportedFileAttributeViews().contains("posix"),
+ "POSIX permissions are not a concept on this filesystem");
+ }
+
+ @Test
+ void aMissingParentDirectoryFailsWithAnActionableMessage(@TempDir Path tempDir) {
+ Path published = tempDir.resolve("nested").resolve("report.bin");
+
+ assertThatThrownBy(() -> AtomicFileOutput.write(published, OutputStream::flush))
+ .isInstanceOf(NoSuchFileException.class)
+ .hasMessageContaining("parent directory");
+ }
+
+ private static List listFiles(Path directory) throws IOException {
+ try (var entries = Files.list(directory)) {
+ return entries.sorted().toList();
+ }
+ }
+}
diff --git a/core/src/test/java/com/demcha/compose/document/backend/fixed/BackendProvidersSelectionTest.java b/core/src/test/java/com/demcha/compose/document/backend/fixed/BackendProvidersSelectionTest.java
index 8a74b1a0..bc26c698 100644
--- a/core/src/test/java/com/demcha/compose/document/backend/fixed/BackendProvidersSelectionTest.java
+++ b/core/src/test/java/com/demcha/compose/document/backend/fixed/BackendProvidersSelectionTest.java
@@ -39,4 +39,21 @@ void pdfStaysMissingDespiteOtherRegisteredProviders() {
.hasMessageContaining("graph-compose-render-pdf")
.hasMessageContaining("graph-compose-bundle");
}
+
+ @Test
+ void twoProvidersClaimingOneFormatFailInsteadOfLettingClasspathOrderDecide() {
+ assertThatThrownBy(() -> BackendProviders.fixedLayout(DuplicateFormatFakeProviders.FORMAT))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("\"dup\"")
+ .hasMessageContaining("DuplicateFormatFakeProviders$One")
+ .hasMessageContaining("DuplicateFormatFakeProviders$Two");
+ }
+
+ @Test
+ void anAmbiguousFormatDoesNotDisturbTheUnambiguousOnes() {
+ // The duplicate registration must stay contained: resolving it throws,
+ // but every other format still resolves, and the default is unchanged.
+ assertThat(BackendProviders.fixedLayout("aaa").format()).isEqualTo("aaa");
+ assertThat(BackendProviders.fixedLayout().format()).isEqualTo("aaa");
+ }
}
diff --git a/core/src/test/java/com/demcha/compose/document/backend/fixed/DuplicateFormatFakeProviders.java b/core/src/test/java/com/demcha/compose/document/backend/fixed/DuplicateFormatFakeProviders.java
new file mode 100644
index 00000000..8e75135c
--- /dev/null
+++ b/core/src/test/java/com/demcha/compose/document/backend/fixed/DuplicateFormatFakeProviders.java
@@ -0,0 +1,50 @@
+package com.demcha.compose.document.backend.fixed;
+
+import com.demcha.compose.document.output.DocumentDebugOptions;
+import com.demcha.compose.document.output.DocumentOutputOptions;
+
+/**
+ * Two ServiceLoader fakes that both declare format {@code "dup"} — the shape a
+ * classpath takes when a third-party plugin ships a backend for a format the
+ * official artifact already provides. Selection must refuse the ambiguity
+ * rather than let enumeration order pick the renderer.
+ *
+ * The 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 listFiles(Path directory) throws IOException {
+ try (var entries = Files.list(directory)) {
+ return entries.sorted().toList();
+ }
+ }
+}
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSessionConvenienceTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSessionConvenienceTest.java
index 71fab6c0..0f853427 100644
--- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSessionConvenienceTest.java
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSessionConvenienceTest.java
@@ -102,31 +102,27 @@ void buildPptxWritesTheSuppliedFile(@TempDir Path tempDir) throws Exception {
}
@Test
- void buildPptxUsesTheConfiguredDefaultOutputFile(@TempDir Path tempDir) throws Exception {
- Path target = tempDir.resolve("default-deck.pptx");
- try (DocumentSession session = GraphCompose.document(target)
+ void buildPptxNeverWritesDeckBytesIntoTheSessionsDefaultPdfFile(@TempDir Path tempDir) throws Exception {
+ // The session's default output file is a PDF target. PPTX output takes an
+ // explicit path, so the deck can never land in it by omission.
+ Path pdfTarget = tempDir.resolve("report.pdf");
+ Path deckTarget = tempDir.resolve("report.pptx");
+ try (DocumentSession session = GraphCompose.document(pdfTarget)
.pageSize(400, 300)
.margin(DocumentInsets.of(24))
.create()) {
session.add(session.dsl().shape().name("Card").size(140, 40)
.fillColor(DocumentColor.ROYAL_BLUE)
.build());
- session.buildPptx();
- try (XMLSlideShow show = new XMLSlideShow(Files.newInputStream(target))) {
+ session.buildPptx(deckTarget);
+
+ assertThat(pdfTarget).doesNotExist();
+ try (XMLSlideShow show = new XMLSlideShow(Files.newInputStream(deckTarget))) {
assertThat(show.getSlides()).hasSize(1);
}
}
}
- @Test
- void buildPptxWithoutADefaultOutputFileFails() {
- try (DocumentSession session = composeWithChrome()) {
- assertThatThrownBy(session::buildPptx)
- .isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("default output file");
- }
- }
-
@Test
void anEmptySessionRefusesToRender() {
try (DocumentSession session = GraphCompose.document()