diff --git a/CHANGELOG.md b/CHANGELOG.md index ff5af3ac..9727d907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..v2.*` → `com.demcha.compose.document.templates..*` for `cv`, `coverletter`, diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDeterminismWriter.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDeterminismWriter.java new file mode 100644 index 00000000..06d8102a --- /dev/null +++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfDeterminismWriter.java @@ -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. + * + *

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.

+ * + * @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; + } +} diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java index 36a9a013..082fe89a 100644 --- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java +++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java @@ -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; @@ -60,15 +61,30 @@ public final class PdfFixedLayoutBackend implements FixedLayoutRenderer { private final PdfViewerPreferencesOptions viewerPreferencesOptions; private final List 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> 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> handlers, @@ -77,7 +93,8 @@ private PdfFixedLayoutBackend(Collection> PdfWatermarkOptions watermarkOptions, PdfProtectionOptions protectionOptions, PdfViewerPreferencesOptions viewerPreferencesOptions, - Collection headerFooterOptions) { + Collection headerFooterOptions, + Instant deterministicTimestamp) { Map, PdfFragmentRenderHandler> registry = new LinkedHashMap<>(); for (PdfFragmentRenderHandler handler : handlers) { PdfFragmentRenderHandler previous = registry.put(handler.payloadType(), Objects.requireNonNull(handler, "handler")); @@ -92,6 +109,7 @@ private PdfFixedLayoutBackend(Collection> this.protectionOptions = protectionOptions; this.viewerPreferencesOptions = viewerPreferencesOptions; this.headerFooterOptions = List.copyOf(headerFooterOptions); + this.deterministicTimestamp = deterministicTimestamp; } /** @@ -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) { @@ -502,6 +523,9 @@ private PDDocument buildSectionsDocument(List 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(); @@ -736,6 +760,7 @@ public static final class Builder { private PdfWatermarkOptions watermarkOptions; private PdfProtectionOptions protectionOptions; private PdfViewerPreferencesOptions viewerPreferencesOptions; + private Instant deterministicTimestamp; private Builder() { } @@ -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. + * + *

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.

+ * + * @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. * @@ -892,7 +957,8 @@ public PdfFixedLayoutBackend build() { watermarkOptions, protectionOptions, viewerPreferencesOptions, - headerFooterOptions); + headerFooterOptions, + deterministicTimestamp); } } } diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfDeterminismWriterTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfDeterminismWriterTest.java new file mode 100644 index 00000000..4b14d9d2 --- /dev/null +++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfDeterminismWriterTest.java @@ -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); + } +} diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfDeterministicOutputTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfDeterministicOutputTest.java new file mode 100644 index 00000000..6c4806ec --- /dev/null +++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfDeterministicOutputTest.java @@ -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(); + } + } +}