Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions core/src/main/java/com/demcha/compose/GraphCompose.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*
* <p><b>Permissions.</b> {@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.</p>
*
* <p><b>Symbolic links.</b> Publishing replaces the destination <em>entry</em>.
* 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.</p>
*
* @author Artem Demchyshyn
*/
final class AtomicFileOutput {

private static final Logger LOG = LoggerFactory.getLogger("com.demcha.compose.document.lifecycle");

private static final Set<PosixFilePermission> 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<PosixFilePermission> 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.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
* <li>inspect {@link #layoutGraph()} / {@link #layoutSnapshot()} as needed</li>
* <li>render with {@link #writePdf(OutputStream)}, {@link #toPdfBytes()}, {@link #buildPdf()},
* their PPTX counterparts ({@link #writePptx(OutputStream)}, {@link #toPptxBytes()},
* {@link #buildPptx()}), or a custom backend</li>
* {@link #buildPptx(Path)}), or a custom backend</li>
* </ol>
*
* <p><b>Thread-safety:</b> this type is mutable and not thread-safe.</p>
Expand Down Expand Up @@ -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.
*
* <p>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.</p>
*
* @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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,13 +77,16 @@ public static FixedLayoutBackendProvider fixedLayout() {
* format, resolving and caching it on first use.
*
* <p>Matching against {@link FixedLayoutBackendProvider#format()} is
* case-insensitive. When several providers declare the same format the
* first one enumerated by {@link ServiceLoader} wins.</p>
* 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.</p>
*
* @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) {
Expand All @@ -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<FixedLayoutBackendProvider> 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);
}

/**
Expand All @@ -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));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.</p>
*
Expand Down
Loading
Loading