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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ for this cycle.

### Public API

- **Reproducible PDF output** (`@Beta`). `PdfFixedLayoutBackend.builder().deterministic(true)`
(or `.deterministic(Instant)` for an explicit timestamp) pins the document
CreationDate / ModDate and derives the PDF `/ID` from the document metadata instead
of PDFBox's time-seeded default, so the same document renders to byte-identical PDF
bytes across runs — for reproducible builds and byte-level output tests. Off by
default (output keeps the live timestamp and `/ID`). PDF encryption via `protect(...)`
can reintroduce randomness (AES-256 uses random salts), so an encrypted document is
not byte-reproducible even with this enabled.
- The layered template packages dropped their `.v2` suffix:
`com.demcha.compose.document.templates.<family>.v2.*` →
`com.demcha.compose.document.templates.<family>.*` for `cv`, `coverletter`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.demcha.compose.document.backend.fixed.pdf;

import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Calendar;
import java.util.GregorianCalendar;

/**
* Makes a {@link PDDocument} render to byte-identical output across runs: pins
* the CreationDate / ModDate to a fixed instant and replaces PDFBox's
* time-seeded {@code /ID} trailer with one derived from the document's info
* dictionary. Applied by {@link PdfFixedLayoutBackend} only when deterministic
* output is enabled.
*
* <p>The {@code /ID} is derived from the info-dictionary values (not the page
* content), so documents with identical metadata but different content share an
* {@code /ID}. Package-private — engine surface, not public API.</p>
*
* @author Artem Demchyshyn
*/
final class PdfDeterminismWriter {

private PdfDeterminismWriter() {
// Utility class, no instantiation.
}

/**
* Pins the document's CreationDate / ModDate to {@code timestamp} and sets a
* stable {@code /ID} derived from the info dictionary.
*
* @param document the assembled document, mutated in place
* @param timestamp the instant to pin CreationDate / ModDate to
*/
static void apply(PDDocument document, Instant timestamp) {
Calendar pinned = GregorianCalendar.from(timestamp.atZone(ZoneOffset.UTC));
PDDocumentInformation info = document.getDocumentInformation();
info.setCreationDate(pinned);
info.setModificationDate(pinned);

COSString idString = new COSString(documentId(info, timestamp));
COSArray idArray = new COSArray();
idArray.add(idString);
idArray.add(idString);
document.getDocument().getTrailer().setItem(COSName.ID, idArray);
}

/**
* Derives a stable 16-byte {@code /ID} from the info dictionary and the pinned
* timestamp — deterministic across runs, and distinct for documents whose
* metadata differs.
*/
static byte[] documentId(PDDocumentInformation info, Instant timestamp) {
String seed = String.join(" ",
String.valueOf(timestamp),
nullToEmpty(info.getTitle()),
nullToEmpty(info.getAuthor()),
nullToEmpty(info.getSubject()),
nullToEmpty(info.getCreator()),
nullToEmpty(info.getProducer()));
try {
return MessageDigest.getInstance("MD5").digest(seed.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException ex) {
// MD5 is guaranteed on every JRE; unreachable in practice.
throw new IllegalStateException("MD5 unavailable for deterministic PDF /ID", ex);
}
}

private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.TimeUnit;

Expand Down Expand Up @@ -60,15 +61,30 @@ public final class PdfFixedLayoutBackend implements FixedLayoutRenderer {
private final PdfViewerPreferencesOptions viewerPreferencesOptions;
private final List<PdfHeaderFooterOptions> headerFooterOptions;

/**
* Default timestamp for {@link Builder#deterministic(boolean) deterministic(true)}
* when no explicit instant is supplied. Any fixed constant works; a pinned epoch
* is what keeps the output byte-identical across runs.
*/
private static final Instant DEFAULT_DETERMINISTIC_INSTANT = Instant.parse("2000-01-01T00:00:00Z");

/**
* When non-null, deterministic output is on: the document CreationDate / ModDate
* are pinned to this instant and the PDF {@code /ID} is derived from the document
* metadata rather than PDFBox's time-seeded default, so the same document renders
* to byte-identical output across runs ({@code null} = off).
*/
private final Instant deterministicTimestamp;

/**
* Creates a backend with the built-in paragraph, shape, image, and table handlers.
*/
public PdfFixedLayoutBackend() {
this(defaultHandlers(), DocumentDebugOptions.none(), null, null, null, null, List.of());
this(defaultHandlers(), DocumentDebugOptions.none(), null, null, null, null, List.of(), null);
}

PdfFixedLayoutBackend(Collection<? extends PdfFragmentRenderHandler<?>> handlers) {
this(handlers, DocumentDebugOptions.none(), null, null, null, null, List.of());
this(handlers, DocumentDebugOptions.none(), null, null, null, null, List.of(), null);
}

private PdfFixedLayoutBackend(Collection<? extends PdfFragmentRenderHandler<?>> handlers,
Expand All @@ -77,7 +93,8 @@ private PdfFixedLayoutBackend(Collection<? extends PdfFragmentRenderHandler<?>>
PdfWatermarkOptions watermarkOptions,
PdfProtectionOptions protectionOptions,
PdfViewerPreferencesOptions viewerPreferencesOptions,
Collection<PdfHeaderFooterOptions> headerFooterOptions) {
Collection<PdfHeaderFooterOptions> headerFooterOptions,
Instant deterministicTimestamp) {
Map<Class<?>, PdfFragmentRenderHandler<?>> registry = new LinkedHashMap<>();
for (PdfFragmentRenderHandler<?> handler : handlers) {
PdfFragmentRenderHandler<?> previous = registry.put(handler.payloadType(), Objects.requireNonNull(handler, "handler"));
Expand All @@ -92,6 +109,7 @@ private PdfFixedLayoutBackend(Collection<? extends PdfFragmentRenderHandler<?>>
this.protectionOptions = protectionOptions;
this.viewerPreferencesOptions = viewerPreferencesOptions;
this.headerFooterOptions = List.copyOf(headerFooterOptions);
this.deterministicTimestamp = deterministicTimestamp;
}

/**
Expand Down Expand Up @@ -380,6 +398,9 @@ private PDDocument buildDocument(LayoutGraph graph, FixedLayoutRenderContext con
protectionOptions,
headerFooterOptions);
PdfDocumentPostProcessor.applyViewerPreferences(document, viewerPreferencesOptions);
if (deterministicTimestamp != null) {
PdfDeterminismWriter.apply(document, deterministicTimestamp);
}

return document;
} catch (Exception ex) {
Expand Down Expand Up @@ -502,6 +523,9 @@ private PDDocument buildSectionsDocument(List<SectionUnit> sections) throws Exce
PdfBookmarkOutlineWriter.apply(document, List.copyOf(bookmarks));
PdfInternalLinkWriter.apply(document, Map.copyOf(anchors), List.copyOf(links));
applyDocumentMetadataAndProtection(document, sections);
if (deterministicTimestamp != null) {
PdfDeterminismWriter.apply(document, deterministicTimestamp);
}
return document;
} catch (Exception ex) {
document.close();
Expand Down Expand Up @@ -736,6 +760,7 @@ public static final class Builder {
private PdfWatermarkOptions watermarkOptions;
private PdfProtectionOptions protectionOptions;
private PdfViewerPreferencesOptions viewerPreferencesOptions;
private Instant deterministicTimestamp;

private Builder() {
}
Expand Down Expand Up @@ -873,6 +898,46 @@ public Builder footer(PdfHeaderFooterOptions options) {
return this;
}

/**
* Enables (or disables) deterministic output. When enabled, the document
* CreationDate / ModDate are pinned to a fixed default timestamp and the
* PDF {@code /ID} is derived from the document metadata instead of PDFBox's
* time-seeded default, so the same document renders to byte-identical output
* across runs — for reproducible builds and byte-level output tests. Disabled
* by default.
*
* <p>Two documents whose metadata is identical share an {@code /ID} (it is
* derived from the info dictionary, not the page content). PDF encryption via
* {@link #protect(PdfProtectionOptions)} can reintroduce randomness — AES-256
* uses random salts — so an encrypted document is not byte-reproducible even
* with this enabled.</p>
*
* @param enabled {@code true} to pin output at the default timestamp,
* {@code false} to keep PDFBox's live timestamp and {@code /ID}
* @return this builder
* @since 2.0.0
*/
@Beta
public Builder deterministic(boolean enabled) {
this.deterministicTimestamp = enabled ? DEFAULT_DETERMINISTIC_INSTANT : null;
return this;
}

/**
* Enables deterministic output with an explicit timestamp for the document
* CreationDate / ModDate. See {@link #deterministic(boolean)}.
*
* @param timestamp the instant to pin CreationDate / ModDate to
* @return this builder
* @throws NullPointerException if {@code timestamp} is null
* @since 2.0.0
*/
@Beta
public Builder deterministic(Instant timestamp) {
this.deterministicTimestamp = Objects.requireNonNull(timestamp, "timestamp");
return this;
}

/**
* Creates an immutable PDF backend instance with the configured options.
*
Expand All @@ -892,7 +957,8 @@ public PdfFixedLayoutBackend build() {
watermarkOptions,
protectionOptions,
viewerPreferencesOptions,
headerFooterOptions);
headerFooterOptions,
deterministicTimestamp);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.demcha.compose.document.backend.fixed.pdf;

import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.junit.jupiter.api.Test;

import java.time.Instant;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Unit contract for the deterministic {@code /ID} derivation. The full
* render-twice byte-identity guarantee lives in {@link PdfDeterministicOutputTest};
* these pin the id-from-metadata logic in isolation.
*/
class PdfDeterminismWriterTest {

private static final Instant T = Instant.parse("2020-05-15T00:00:00Z");

private static PDDocumentInformation info(String title) {
PDDocumentInformation info = new PDDocumentInformation();
info.setTitle(title);
return info;
}

@Test
void idIsStableForTheSameMetadataAndTimestamp() {
assertThat(PdfDeterminismWriter.documentId(info("Report"), T))
.isEqualTo(PdfDeterminismWriter.documentId(info("Report"), T));
}

@Test
void idDiffersWhenMetadataDiffers() {
assertThat(PdfDeterminismWriter.documentId(info("Report"), T))
.isNotEqualTo(PdfDeterminismWriter.documentId(info("Invoice"), T));
}

@Test
void idDiffersWhenTimestampDiffers() {
assertThat(PdfDeterminismWriter.documentId(info("Report"), T))
.isNotEqualTo(PdfDeterminismWriter.documentId(info("Report"), Instant.EPOCH));
}

@Test
void idIsA16ByteHashEvenWithNoMetadata() {
assertThat(PdfDeterminismWriter.documentId(new PDDocumentInformation(), T)).hasSize(16);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.demcha.compose.document.backend.fixed.pdf;

import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.node.ShapeNode;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextStyle;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.Test;

import java.awt.Color;
import java.time.Instant;
import java.util.Calendar;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Reproducible-output contract for {@link PdfFixedLayoutBackend}. With
* {@code deterministic(...)} the same document renders to byte-identical PDF
* output across runs — the CreationDate / ModDate are pinned and the {@code /ID}
* is derived from the metadata instead of PDFBox's time-seeded default. The
* default backend leaves the live timestamp untouched.
*/
class PdfDeterministicOutputTest {

private static byte[] render(PdfFixedLayoutBackend backend) throws Exception {
try (DocumentSession session = GraphCompose.document()
.pageSize(200, 200)
.margin(DocumentInsets.of(10))
.create()) {
session.add(new ShapeNode("Box", 80, 80, Color.LIGHT_GRAY,
DocumentStroke.of(DocumentColor.BLACK, 1), DocumentInsets.zero(), DocumentInsets.zero()));
return session.render(backend);
}
}

private static byte[] renderText(PdfFixedLayoutBackend backend) throws Exception {
try (DocumentSession session = GraphCompose.document()
.pageSize(240, 180)
.margin(DocumentInsets.of(12))
.create()) {
session.pageFlow()
.name("Root")
.addParagraph("Reproducible text embeds a font subset.", DocumentTextStyle.DEFAULT)
.build();
return session.render(backend);
}
}

@Test
void deterministicOutputIsByteIdenticalAcrossRuns() throws Exception {
byte[] first = render(PdfFixedLayoutBackend.builder().deterministic(true).build());
byte[] second = render(PdfFixedLayoutBackend.builder().deterministic(true).build());

assertThat(second).isEqualTo(first);
}

@Test
void deterministicOutputIsByteIdenticalForEmbeddedFontText() throws Exception {
// Text embeds a font subset — the part of the output most likely to vary
// run-to-run if subset naming were non-deterministic.
byte[] first = renderText(PdfFixedLayoutBackend.builder().deterministic(true).build());
byte[] second = renderText(PdfFixedLayoutBackend.builder().deterministic(true).build());

assertThat(second).isEqualTo(first);
}

@Test
void deterministicOutputPinsTheCreationDate() throws Exception {
Instant pinned = Instant.parse("2020-05-15T00:00:00Z");

byte[] bytes = render(PdfFixedLayoutBackend.builder().deterministic(pinned).build());

try (PDDocument document = Loader.loadPDF(bytes)) {
Calendar creation = document.getDocumentInformation().getCreationDate();
assertThat(creation).isNotNull();
assertThat(creation.toInstant()).isEqualTo(pinned);
}
}

@Test
void defaultOutputLeavesTheCreationDateUnset() throws Exception {
byte[] bytes = render(PdfFixedLayoutBackend.builder().build());

try (PDDocument document = Loader.loadPDF(bytes)) {
assertThat(document.getDocumentInformation().getCreationDate()).isNull();
}
}
}
Loading