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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<family>.v2.*` →
`com.demcha.compose.document.templates.<family>.*` for `cv`, `coverletter`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -21,7 +26,7 @@
* 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
* <p>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.</p>
*
Expand All @@ -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
Expand All @@ -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<COSName> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -927,14 +928,19 @@ public Builder deterministic(boolean enabled) {
* Enables deterministic output with an explicit timestamp for the document
* CreationDate / ModDate. See {@link #deterministic(boolean)}.
*
* <p>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.</p>
*
* @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");
this.deterministicTimestamp =
Objects.requireNonNull(timestamp, "timestamp").truncatedTo(ChronoUnit.SECONDS);
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand All @@ -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");
Expand All @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading