From 3a0abc3b296a53b30db532c199ee6fe544f238e3 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 11 Jul 2026 11:05:00 +0100 Subject: [PATCH] fix(pdf): reach deterministic output from the multi-section path, harden the /ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups to the deterministic() backend option: - MultiSectionDocument hard-wired its own default backend, so a configured PdfFixedLayoutBackend could not reach the section path through the public API. Add toPdfBytes(FixedLayoutRenderer) and writePdf(FixedLayoutRenderer, OutputStream) overloads — the multi-section counterpart of DocumentSession.render(backend); the no-arg methods delegate to them with the built-in default. - The /ID seed only covered five info fields joined with spaces, so keywords and custom metadata were ignored and shifted field boundaries could collide ("A B"+"" vs "A"+"B"). Derive it from the complete info dictionary as sorted, length-prefixed key/value frames hashed with SHA-256 (first 16 bytes). - deterministic(Instant) now truncates to whole seconds up front — PDF dates carry second precision, so a sub-second instant previously pinned a second-precision date while the /ID used the full-precision value. New coverage: multi-section byte-identity through GraphCompose.documents(), sub-second truncation equivalence, field-boundary collision and keywords/custom-field participation in the /ID. --- CHANGELOG.md | 5 +- .../fixed/pdf/PdfDeterminismWriter.java | 59 +++++++++++++------ .../fixed/pdf/PdfFixedLayoutBackend.java | 8 ++- .../fixed/pdf/PdfDeterminismWriterTest.java | 28 +++++++++ .../fixed/pdf/PdfDeterministicOutputTest.java | 48 +++++++++++++++ .../document/api/MultiSectionDocument.java | 39 +++++++++++- 6 files changed, 166 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9727d907..1256eefd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,10 @@ for this cycle. 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. + not byte-reproducible even with this enabled. Multi-section documents opt in through + the new `MultiSectionDocument.toPdfBytes(FixedLayoutRenderer)` / + `writePdf(FixedLayoutRenderer, OutputStream)` overloads — the multi-section + counterpart of `DocumentSession.render(backend)`. - 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 index 06d8102a..ece1534a 100644 --- 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 @@ -1,6 +1,7 @@ package com.demcha.compose.document.backend.fixed.pdf; import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.cos.COSString; import org.apache.pdfbox.pdmodel.PDDocument; @@ -11,8 +12,12 @@ import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; +import java.util.Comparator; import java.util.GregorianCalendar; +import java.util.List; /** * Makes a {@link PDDocument} render to byte-identical output across runs: pins @@ -21,7 +26,7 @@ * dictionary. Applied by {@link PdfFixedLayoutBackend} only when deterministic * output is enabled. * - *

The {@code /ID} is derived from the info-dictionary values (not the page + *

The {@code /ID} is derived from the info-dictionary entries (not the page * content), so documents with identical metadata but different content share an * {@code /ID}. Package-private — engine surface, not public API.

* @@ -35,7 +40,8 @@ private PdfDeterminismWriter() { /** * Pins the document's CreationDate / ModDate to {@code timestamp} and sets a - * stable {@code /ID} derived from the info dictionary. + * stable {@code /ID} derived from the info dictionary (the pinned dates are + * set first, so they participate in the id). * * @param document the assembled document, mutated in place * @param timestamp the instant to pin CreationDate / ModDate to @@ -54,27 +60,46 @@ static void apply(PDDocument document, Instant timestamp) { } /** - * 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. + * Derives a stable 16-byte {@code /ID} from the pinned timestamp and the + * complete info dictionary — every entry participates (title, author, + * keywords, custom fields, …), canonicalized as sorted, length-prefixed + * key/value frames so distinct dictionaries cannot collide structurally. + * Deterministic across runs; SHA-256 truncated to the 16 bytes a PDF + * {@code /ID} carries. */ 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)); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + putFrame(digest, String.valueOf(timestamp)); + List keys = new ArrayList<>(info.getCOSObject().keySet()); + keys.sort(Comparator.comparing(COSName::getName)); + for (COSName key : keys) { + putFrame(digest, key.getName()); + putFrame(digest, stringValue(info.getCOSObject().getDictionaryObject(key))); + } + return Arrays.copyOf(digest.digest(), 16); } catch (NoSuchAlgorithmException ex) { - // MD5 is guaranteed on every JRE; unreachable in practice. - throw new IllegalStateException("MD5 unavailable for deterministic PDF /ID", ex); + // SHA-256 is guaranteed on every JRE; unreachable in practice. + throw new IllegalStateException("SHA-256 unavailable for deterministic PDF /ID", ex); } } - private static String nullToEmpty(String value) { - return value == null ? "" : value; + /** Length-prefixes each value so adjacent frames cannot blur into each other. */ + private static void putFrame(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + digest.update(new byte[] { + (byte) (bytes.length >>> 24), (byte) (bytes.length >>> 16), + (byte) (bytes.length >>> 8), (byte) bytes.length}); + digest.update(bytes); + } + + private static String stringValue(COSBase value) { + if (value instanceof COSString string) { + return string.getString(); + } + if (value instanceof COSName name) { + return name.getName(); + } + return String.valueOf(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 082fe89a..adbb1546 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 @@ -32,6 +32,7 @@ import java.io.OutputStream; import java.nio.file.Files; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.*; import java.util.concurrent.TimeUnit; @@ -927,6 +928,10 @@ public Builder deterministic(boolean enabled) { * Enables deterministic output with an explicit timestamp for the document * CreationDate / ModDate. See {@link #deterministic(boolean)}. * + *

The instant is truncated to whole seconds: PDF dates carry second + * precision, so truncating up front keeps the serialized dates and the + * derived {@code /ID} in agreement for sub-second inputs.

+ * * @param timestamp the instant to pin CreationDate / ModDate to * @return this builder * @throws NullPointerException if {@code timestamp} is null @@ -934,7 +939,8 @@ public Builder deterministic(boolean enabled) { */ @Beta public Builder deterministic(Instant timestamp) { - this.deterministicTimestamp = Objects.requireNonNull(timestamp, "timestamp"); + this.deterministicTimestamp = + Objects.requireNonNull(timestamp, "timestamp").truncatedTo(ChronoUnit.SECONDS); return this; } 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 index 4b14d9d2..68dc4fa7 100644 --- 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 @@ -44,4 +44,32 @@ void idDiffersWhenTimestampDiffers() { void idIsA16ByteHashEvenWithNoMetadata() { assertThat(PdfDeterminismWriter.documentId(new PDDocumentInformation(), T)).hasSize(16); } + + @Test + void idDoesNotCollideWhenFieldBoundariesShift() { + // "A B" + "" vs "A" + "B" concatenate identically without length-prefixed + // frames; the canonical form must keep them apart. + PDDocumentInformation first = new PDDocumentInformation(); + first.setTitle("A B"); + first.setAuthor(""); + PDDocumentInformation second = new PDDocumentInformation(); + second.setTitle("A"); + second.setAuthor("B"); + + assertThat(PdfDeterminismWriter.documentId(first, T)) + .isNotEqualTo(PdfDeterminismWriter.documentId(second, T)); + } + + @Test + void keywordsAndCustomFieldsParticipateInTheId() { + byte[] base = PdfDeterminismWriter.documentId(info("Report"), T); + + PDDocumentInformation withKeywords = info("Report"); + withKeywords.setKeywords("alpha beta"); + PDDocumentInformation withCustom = info("Report"); + withCustom.setCustomMetadataValue("Team", "Docs"); + + assertThat(PdfDeterminismWriter.documentId(withKeywords, T)).isNotEqualTo(base); + assertThat(PdfDeterminismWriter.documentId(withCustom, T)).isNotEqualTo(base); + } } 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 index 6c4806ec..4ae2d3bf 100644 --- 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 @@ -2,6 +2,7 @@ import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.api.MultiSectionDocument; import com.demcha.compose.document.node.ShapeNode; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; @@ -50,6 +51,30 @@ private static byte[] renderText(PdfFixedLayoutBackend backend) throws Exception } } + /** Two sections with different page geometry, combined via the public path. */ + private static byte[] renderMultiSection(PdfFixedLayoutBackend backend) throws Exception { + DocumentSession cover = GraphCompose.document() + .pageSize(200, 200) + .margin(DocumentInsets.of(10)) + .create(); + cover.add(new ShapeNode("Cover", 80, 80, Color.LIGHT_GRAY, + DocumentStroke.of(DocumentColor.BLACK, 1), DocumentInsets.zero(), DocumentInsets.zero())); + DocumentSession body = GraphCompose.document() + .pageSize(240, 180) + .margin(DocumentInsets.of(12)) + .create(); + body.pageFlow() + .name("Body") + .addParagraph("Reproducible multi-section body.", DocumentTextStyle.DEFAULT) + .build(); + try (MultiSectionDocument document = GraphCompose.documents() + .section(cover) + .section(body) + .create()) { + return document.toPdfBytes(backend); + } + } + @Test void deterministicOutputIsByteIdenticalAcrossRuns() throws Exception { byte[] first = render(PdfFixedLayoutBackend.builder().deterministic(true).build()); @@ -68,6 +93,16 @@ void deterministicOutputIsByteIdenticalForEmbeddedFontText() throws Exception { assertThat(second).isEqualTo(first); } + @Test + void deterministicOutputIsByteIdenticalForMultiSectionDocuments() throws Exception { + // The public GraphCompose.documents() path: the configured backend is + // passed to toPdfBytes(backend) instead of the built-in default. + byte[] first = renderMultiSection(PdfFixedLayoutBackend.builder().deterministic(true).build()); + byte[] second = renderMultiSection(PdfFixedLayoutBackend.builder().deterministic(true).build()); + + assertThat(second).isEqualTo(first); + } + @Test void deterministicOutputPinsTheCreationDate() throws Exception { Instant pinned = Instant.parse("2020-05-15T00:00:00Z"); @@ -81,6 +116,19 @@ void deterministicOutputPinsTheCreationDate() throws Exception { } } + @Test + void subSecondTimestampsTruncateToWholeSeconds() throws Exception { + // PDF dates carry second precision; the builder truncates up front so the + // serialized dates and the /ID agree — a sub-second instant renders the + // same bytes as its whole-second truncation. + byte[] withNanos = render(PdfFixedLayoutBackend.builder() + .deterministic(Instant.parse("2020-05-15T00:00:00.123456789Z")).build()); + byte[] wholeSecond = render(PdfFixedLayoutBackend.builder() + .deterministic(Instant.parse("2020-05-15T00:00:00Z")).build()); + + assertThat(withNanos).isEqualTo(wholeSecond); + } + @Test void defaultOutputLeavesTheCreationDateUnset() throws Exception { byte[] bytes = render(PdfFixedLayoutBackend.builder().build()); diff --git a/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java b/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java index 5ae7f530..7ea1ff6b 100644 --- a/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java +++ b/src/main/java/com/demcha/compose/document/api/MultiSectionDocument.java @@ -57,22 +57,57 @@ public final class MultiSectionDocument implements AutoCloseable { } /** - * Renders the combined multi-section document to PDF bytes. + * Renders the combined multi-section document to PDF bytes with the default + * backend. * * @return rendered PDF bytes * @throws DocumentRenderingException if rendering fails */ public byte[] toPdfBytes() throws DocumentRenderingException { + return toPdfBytes(backend); + } + + /** + * Renders the combined multi-section document to PDF bytes with the supplied + * backend — the multi-section counterpart of + * {@link DocumentSession#render(com.demcha.compose.document.backend.fixed.FixedLayoutBackend)}. + * Use this to apply backend-level options to the combined document, e.g. + * {@code PdfFixedLayoutBackend.builder().deterministic(true).build()} for + * reproducible output. + * + * @param backend the configured section-capable backend to render with + * @return rendered PDF bytes + * @throws DocumentRenderingException if rendering fails + * @since 2.0.0 + */ + public byte[] toPdfBytes(FixedLayoutRenderer backend) throws DocumentRenderingException { + Objects.requireNonNull(backend, "backend"); return render("render PDF bytes", () -> backend.renderSections(renderUnits())); } /** - * Streams the combined document to the caller-owned stream (not closed here). + * Streams the combined document to the caller-owned stream (not closed here) + * with the default backend. * * @param output destination stream that receives the rendered PDF bytes * @throws DocumentRenderingException if rendering fails */ public void writePdf(OutputStream output) throws DocumentRenderingException { + writePdf(backend, output); + } + + /** + * Streams the combined document to the caller-owned stream (not closed here) + * with the supplied backend. See {@link #toPdfBytes(FixedLayoutRenderer)} for + * when to pass a configured backend. + * + * @param backend the configured section-capable backend to render with + * @param output destination stream that receives the rendered PDF bytes + * @throws DocumentRenderingException if rendering fails + * @since 2.0.0 + */ + public void writePdf(FixedLayoutRenderer backend, OutputStream output) throws DocumentRenderingException { + Objects.requireNonNull(backend, "backend"); Objects.requireNonNull(output, "output"); render("write PDF to stream", () -> { backend.writeSections(renderUnits(), output);