diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4b8b4800..3459774c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -91,6 +91,11 @@ jobs:
# The examples module renders figures from the committed perf
# baseline, so a baseline-only refresh changes generated output.
- 'baselines/**'
+ # The committed previews are compared against a fresh render
+ # (CommittedAssetDriftTest), so editing one is a change the
+ # examples job has to check — otherwise a hand-edited or
+ # hand-reverted preview lands with nothing looking at it.
+ - 'assets/readme/**'
- '.mvn/**'
- 'mvnw'
- 'mvnw.cmd'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 347361da..715c4026 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,21 @@ follow semantic versioning; release dates are ISO 8601.
### Build
+- **A committed preview cannot fall behind the code that renders it.** README and
+ the showcase site read files under `assets/readme/**` rather than rendering
+ anything, and nothing held those files to the catalogue: a change to an example,
+ a theme or the engine moved the render while the committed file stayed put, and
+ the first anybody knew was a release publishing it. Twenty-three of the
+ sixty-seven were behind and are re-rendered here; the twenty-two PDFs among
+ them rasterise to the same pixels as before, so nothing visible had been
+ carrying the drift, and the one DOCX now marks bold as `` rather than by
+ asking for a font named `Helvetica-Bold`. Every preview is now
+ compared against a fresh render on each build, exactly: the comparison drops
+ only what a machine writes rather than an author (a PDF's clock-seeded `/ID`, an
+ OOXML package's zip and creation stamps, the platform's line separator, and one
+ named watermark whose antialiasing differs between machines), all of it measured
+ by rendering the catalogue on both platforms rather than assumed. Editing a
+ preview by hand now runs the job that checks it.
- **The CI guard job runs every guard it names.** It selected eight test classes
while scoping the reactor to `graph-compose-core`: two had been deleted months
earlier and two live in `graph-compose-qa` and `graph-compose-render-pdf`, so
diff --git a/assets/readme/examples/block-align.pdf b/assets/readme/examples/block-align.pdf
index 5a3ec418..26695622 100644
Binary files a/assets/readme/examples/block-align.pdf and b/assets/readme/examples/block-align.pdf differ
diff --git a/assets/readme/examples/book-template.pdf b/assets/readme/examples/book-template.pdf
index 492f3e37..7f586c66 100644
Binary files a/assets/readme/examples/book-template.pdf and b/assets/readme/examples/book-template.pdf differ
diff --git a/assets/readme/examples/container-bookmark.pdf b/assets/readme/examples/container-bookmark.pdf
index 293e7d34..59953699 100644
Binary files a/assets/readme/examples/container-bookmark.pdf and b/assets/readme/examples/container-bookmark.pdf differ
diff --git a/assets/readme/examples/content-bleed.pdf b/assets/readme/examples/content-bleed.pdf
index c1376957..02cc6281 100644
Binary files a/assets/readme/examples/content-bleed.pdf and b/assets/readme/examples/content-bleed.pdf differ
diff --git a/assets/readme/examples/debug-overlay.pdf b/assets/readme/examples/debug-overlay.pdf
index 70ed58f3..0d2a0199 100644
Binary files a/assets/readme/examples/debug-overlay.pdf and b/assets/readme/examples/debug-overlay.pdf differ
diff --git a/assets/readme/examples/in-pdf-navigation.pdf b/assets/readme/examples/in-pdf-navigation.pdf
index 5a9363dd..07ad3872 100644
Binary files a/assets/readme/examples/in-pdf-navigation.pdf and b/assets/readme/examples/in-pdf-navigation.pdf differ
diff --git a/assets/readme/examples/line-cap.pdf b/assets/readme/examples/line-cap.pdf
index 1c0a99df..c6f37503 100644
Binary files a/assets/readme/examples/line-cap.pdf and b/assets/readme/examples/line-cap.pdf differ
diff --git a/assets/readme/examples/line-fill.pdf b/assets/readme/examples/line-fill.pdf
index a5274664..7b4e7a88 100644
Binary files a/assets/readme/examples/line-fill.pdf and b/assets/readme/examples/line-fill.pdf differ
diff --git a/assets/readme/examples/module-first-profile.pdf b/assets/readme/examples/module-first-profile.pdf
index 4cc7888c..6a0d9f05 100644
Binary files a/assets/readme/examples/module-first-profile.pdf and b/assets/readme/examples/module-first-profile.pdf differ
diff --git a/assets/readme/examples/multi-section-document.pdf b/assets/readme/examples/multi-section-document.pdf
index 14503b3e..819e4af2 100644
Binary files a/assets/readme/examples/multi-section-document.pdf and b/assets/readme/examples/multi-section-document.pdf differ
diff --git a/assets/readme/examples/page-numbering.pdf b/assets/readme/examples/page-numbering.pdf
index 3eeac42f..0dbf3dfd 100644
Binary files a/assets/readme/examples/page-numbering.pdf and b/assets/readme/examples/page-numbering.pdf differ
diff --git a/assets/readme/examples/page-reference.pdf b/assets/readme/examples/page-reference.pdf
index ed37ecd0..f1d61eb9 100644
Binary files a/assets/readme/examples/page-reference.pdf and b/assets/readme/examples/page-reference.pdf differ
diff --git a/assets/readme/examples/per-page-margin.pdf b/assets/readme/examples/per-page-margin.pdf
index f0cef8f4..53af6163 100644
Binary files a/assets/readme/examples/per-page-margin.pdf and b/assets/readme/examples/per-page-margin.pdf differ
diff --git a/assets/readme/examples/row-columns.pdf b/assets/readme/examples/row-columns.pdf
index cdbbb871..ec510c03 100644
Binary files a/assets/readme/examples/row-columns.pdf and b/assets/readme/examples/row-columns.pdf differ
diff --git a/assets/readme/examples/row-flex.pdf b/assets/readme/examples/row-flex.pdf
index 7728bb76..e55b0ce8 100644
Binary files a/assets/readme/examples/row-flex.pdf and b/assets/readme/examples/row-flex.pdf differ
diff --git a/assets/readme/examples/row-vertical-align.pdf b/assets/readme/examples/row-vertical-align.pdf
index ff57810a..048315f1 100644
Binary files a/assets/readme/examples/row-vertical-align.pdf and b/assets/readme/examples/row-vertical-align.pdf differ
diff --git a/assets/readme/examples/svg-icon-gallery.pdf b/assets/readme/examples/svg-icon-gallery.pdf
index 13671a81..2d038bc3 100644
Binary files a/assets/readme/examples/svg-icon-gallery.pdf and b/assets/readme/examples/svg-icon-gallery.pdf differ
diff --git a/assets/readme/examples/table-advanced.pdf b/assets/readme/examples/table-advanced.pdf
index 84f3bdad..5b6dfcfe 100644
Binary files a/assets/readme/examples/table-advanced.pdf and b/assets/readme/examples/table-advanced.pdf differ
diff --git a/assets/readme/examples/table-of-contents.pdf b/assets/readme/examples/table-of-contents.pdf
index 6b8570fc..a721966e 100644
Binary files a/assets/readme/examples/table-of-contents.pdf and b/assets/readme/examples/table-of-contents.pdf differ
diff --git a/assets/readme/examples/transforms.pdf b/assets/readme/examples/transforms.pdf
index f2ccf817..cab13efa 100644
Binary files a/assets/readme/examples/transforms.pdf and b/assets/readme/examples/transforms.pdf differ
diff --git a/assets/readme/examples/vector-path.pdf b/assets/readme/examples/vector-path.pdf
index dd73303c..e354df52 100644
Binary files a/assets/readme/examples/vector-path.pdf and b/assets/readme/examples/vector-path.pdf differ
diff --git a/assets/readme/examples/viewer-preferences.pdf b/assets/readme/examples/viewer-preferences.pdf
index 105e573e..a1f37054 100644
Binary files a/assets/readme/examples/viewer-preferences.pdf and b/assets/readme/examples/viewer-preferences.pdf differ
diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx
index 43ff0d05..ca2e12c1 100644
Binary files a/assets/readme/examples/word-export-companion.docx and b/assets/readme/examples/word-export-companion.docx differ
diff --git a/examples/pom.xml b/examples/pom.xml
index 25d86e90..468f043d 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -21,6 +21,23 @@
1.6.0
17
+
+ 2.1.0
+
6.1.2
3.27.7
@@ -175,6 +192,20 @@
org.apache.maven.plugins
maven-surefire-plugin
3.5.6
+
+
+
+
+ ${graphcompose.examples.assetVersion}
+
+
+
diff --git a/examples/src/test/java/com/demcha/examples/CommittedAssetDriftTest.java b/examples/src/test/java/com/demcha/examples/CommittedAssetDriftTest.java
new file mode 100644
index 00000000..e47ce952
--- /dev/null
+++ b/examples/src/test/java/com/demcha/examples/CommittedAssetDriftTest.java
@@ -0,0 +1,262 @@
+package com.demcha.examples;
+
+import com.demcha.examples.support.AssetContent;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Holds the committed previews to the code that produces them.
+ *
+ * The repository commits a preview of part of the example catalogue, and README and the
+ * showcase site read those files rather than rendering anything. Nothing regenerated them, so
+ * they drifted quietly: a change to an example, a theme or the engine moved the render while the
+ * committed file stayed where it was, and the first time anybody noticed was when a release
+ * published it. At the point this guard was written, 23 of the 67 committed previews were behind
+ * the code — and the way that surfaced was a deck losing its bold weights for two releases
+ * without a single test going red.
+ *
+ * The comparison is {@link AssetContent}: exact, with the handful of differences a machine
+ * rather than an author writes named and dropped. So a failure here means the render moved, and
+ * the fix is to re-render the named files or to revert what moved them — never to widen the
+ * comparison.
+ *
+ * A committed preview is rendered at the released version while a working tree is
+ * already on the next one, so this compares like with like only because the examples module runs
+ * its tests with {@code graphcompose.examples.displayVersion} pinned to
+ * {@code graphcompose.examples.assetVersion} — the version the committed files were rendered at,
+ * recorded in {@code examples/pom.xml}. Without that the coordinate pill alone would differ and
+ * this would fail on every preview that carries one.
+ *
+ * Nothing moves that property yet, and this guard cannot notice: it compares both sides at
+ * whatever the property says, so a release that re-renders the showcase site at a new version
+ * while leaving the previews behind passes here. Closing that is the release step's job, and it
+ * is the reason to land it alongside rather than long after.
+ */
+class CommittedAssetDriftTest {
+
+ private static final Path ASSETS = Path.of("..", "assets", "readme");
+ private static final Path PREVIEWS = ASSETS.resolve("examples");
+
+ /** What counts as a document: the catalogue also writes a preview image and a data file. */
+ private static final Set DOCUMENTS = Set.of(".pdf", ".pptx", ".docx");
+
+ /**
+ * The documents the catalogue renders and the repository deliberately does not commit.
+ *
+ * Publishing a preview is a decision, and this is the half of it worth writing down: the
+ * other half is the folder itself, and the two together have to account for the catalogue
+ * exactly. Without that, comparing only the files that happen to be there means deleting one
+ * removes it from its own guard — README would lose a figure and every test would stay
+ * green.
+ *
+ * Listing what is not published rather than what is keeps the shorter list, and
+ * puts the decision where it is actually made: a new example ships unpublished unless somebody
+ * says otherwise, and saying so is adding a file to the folder and a name off this list.
+ */
+ private static final Set UNPUBLISHED_PREVIEWS = Set.of(
+ "cover-letter-blue-banner-v2.pdf",
+ "cover-letter-boxed-sections-v2.pdf",
+ "cover-letter-centered-headline-v2.pdf",
+ "cover-letter-classic-serif-v2.pdf",
+ "cover-letter-compact-mono-v2.pdf",
+ "cover-letter-editorial-blue-v2.pdf",
+ "cover-letter-engineering-resume-v2.pdf",
+ "cover-letter-executive-v2.pdf",
+ "cover-letter-mint-editorial-v2.pdf",
+ "cover-letter-modern-professional-v2.pdf",
+ "cover-letter-monogram-sidebar-v2.pdf",
+ "cover-letter-nordic-clean-v2.pdf",
+ "cover-letter-panel-v2.pdf",
+ "cover-letter-sidebar-portrait-v2.pdf",
+ "cover-letter-timeline-minimal-v2.pdf",
+ "cv-blue-banner-v2.pdf",
+ "cv-boxed-sections-v2.pdf",
+ "cv-centered-headline-v2.pdf",
+ "cv-editorial-blue-v2.pdf",
+ "cv-executive-v2.pdf",
+ "cv-minimal-underlined-v2.pdf",
+ "cv-mint-editorial-v2-custom.pdf",
+ "cv-mint-editorial-v2.pdf",
+ "cv-monogram-sidebar-v2.pdf",
+ "cv-sidebar-portrait-v2.pdf",
+ "emoji-clip-path.pdf",
+ "emoji-gallery.pdf",
+ "emoji-svg-vs-png.pdf",
+ "engine-deck.pptx",
+ "invoice-modern-v2.pdf",
+ "linkedin-carousel.pdf",
+ "linkedin-carousel.pptx",
+ "photo-clip.pdf",
+ "poetry-title.pdf",
+ "proposal-modern-v2.pdf");
+
+ @BeforeAll
+ static void generateEveryExample() throws Exception {
+ GeneratedCatalogue.generateOnce();
+ }
+
+ @Test
+ void everyCommittedPreviewMatchesWhatTheCatalogueRenders() throws Exception {
+ Map generated = generatedByName();
+ List missing = new ArrayList<>();
+ List drifted = new ArrayList<>();
+
+ for (String name : committedPreviews()) {
+ Path fresh = generated.get(name);
+ if (fresh == null) {
+ missing.add(name);
+ } else if (!AssetContent.digestOf(PREVIEWS.resolve(name))
+ .equals(AssetContent.digestOf(fresh))) {
+ drifted.add(name);
+ }
+ }
+
+ assertThat(missing)
+ .describedAs("a committed preview that no example renders is a file nothing can "
+ + "refresh: delete it, or restore the example that produced it")
+ .isEmpty();
+ assertThat(drifted)
+ .describedAs("a committed preview no longer matches what its example renders. The "
+ + "comparison drops what the machine writes, so this is a change to the "
+ + "document: re-render these files, or revert what moved them")
+ .isEmpty();
+ }
+
+ /**
+ * The figures beside the preview folder are rasters, and this cannot compare a raster.
+ *
+ * Everything under {@link #PREVIEWS} is a document, and two machines write one document the
+ * same way once the reduction is applied. These are not: each is a page rasterised to a PNG for
+ * README, and rasterising text is where two machines disagree — the same measurement that put
+ * the showcase watermark in {@link AssetContent#UNSTABLE_PARTS}, over a whole page rather than
+ * one band of it. Comparing them would mean a pixel budget, and a budget is a number nobody can
+ * defend a year later.
+ *
+ * So they are written down instead. The list is what this guard can say about them: a
+ * figure arriving here that is not on it is a file nothing checks, and it should arrive as a
+ * decision rather than as a commit nobody read. The same holds for a folder — {@code v1.5} is
+ * the figures of a released line, where re-rendering would itself be the bug, and a second
+ * folder wants that said out loud rather than assumed.
+ */
+ private static final Set RASTER_FIGURES = Set.of(
+ "barcode-showcase.png",
+ "chart-showcase.png",
+ "feature-catalog.png",
+ "repository_showcase_render.png",
+ "social-card.png",
+ "twin-output-editing.png",
+ "twin-output-pdf.png",
+ "twin-output-pptx.png");
+
+ /** The folders under {@code assets/readme}: the previews this compares, and 1.5's figures. */
+ private static final Set ASSET_FOLDERS = Set.of("examples", "v1.5");
+
+ @Test
+ void theOnlyAssetsThisCannotCompareAreTheOnesWrittenDown() throws Exception {
+ Set beside = new TreeSet<>();
+ Set folders = new TreeSet<>();
+ try (var files = Files.list(ASSETS)) {
+ files.forEach(path -> (Files.isDirectory(path) ? folders : beside)
+ .add(path.getFileName().toString()));
+ }
+
+ assertThat(beside)
+ .describedAs("an asset beside %s is compared by nothing: either move it in with the "
+ + "previews so it is, or add it here with the reason it cannot be", PREVIEWS)
+ .isEqualTo(new TreeSet<>(RASTER_FIGURES));
+ assertThat(folders)
+ .describedAs("a folder of assets beside %s is compared by nothing either, and a "
+ + "whole folder is easier to add without noticing than a file", PREVIEWS)
+ .isEqualTo(new TreeSet<>(ASSET_FOLDERS));
+ }
+
+ /**
+ * The catalogue is exactly the published previews plus the deliberately unpublished ones.
+ *
+ * This is what makes a deletion visible. The comparison above only reads the files that
+ * are there, so removing one takes it out of its own guard: README loses a figure and nothing
+ * goes red. Pinning the whole catalogue against the folder plus the list catches that, and
+ * catches its opposites too — a new example nobody decided to publish, a preview added
+ * without a source, a rename that lands as one of each.
+ */
+ @Test
+ void theCatalogueIsExactlyThePublishedPreviewsPlusTheUnpublishedOnes() throws Exception {
+ Set committed = new TreeSet<>(committedPreviews());
+ assertThat(committed)
+ .describedAs("a preview cannot be published and deliberately unpublished at once")
+ .doesNotContainAnyElementsOf(UNPUBLISHED_PREVIEWS);
+
+ Set accountedFor = new TreeSet<>(committed);
+ accountedFor.addAll(UNPUBLISHED_PREVIEWS);
+
+ Set rendered = new TreeSet<>(generatedByName().keySet());
+ rendered.removeIf(name -> DOCUMENTS.stream().noneMatch(name::endsWith));
+
+ assertThat(rendered)
+ .describedAs("the catalogue, the committed folder and the unpublished list have to "
+ + "cover each other exactly. A document rendered but neither committed nor "
+ + "listed is one nobody decided about; a preview committed but no longer "
+ + "rendered is a file nothing can refresh; and a published preview deleted "
+ + "is a figure README loses — the one this comparison cannot see on its "
+ + "own, since a file that is gone is a file it never looks at")
+ .isEqualTo(accountedFor);
+ }
+
+ /**
+ * The preview folder's files, and it has to be flat to have only files.
+ *
+ * Listing it and keeping the regular files would step over a folder in silence, and a
+ * preview one level down would be in none of the sets these tests compare: not held to the
+ * catalogue, not published, not unpublished, not named as something that cannot be compared.
+ * The site reads this folder flat, so a folder inside it is a mistake worth naming rather
+ * than passing over.
+ */
+ private static Set committedPreviews() throws IOException {
+ Set names = new TreeSet<>();
+ Set folders = new TreeSet<>();
+ try (var files = Files.list(PREVIEWS)) {
+ files.forEach(path -> (Files.isDirectory(path) ? folders : names)
+ .add(path.getFileName().toString()));
+ }
+ assertThat(folders)
+ .describedAs("%s is read flat, so a folder inside it holds previews nothing "
+ + "compares: move them up beside the others", PREVIEWS)
+ .isEmpty();
+ return names;
+ }
+
+ /**
+ * The catalogue keyed by file name, which is how the flat preview folder addresses it.
+ *
+ * Two rendered documents sharing a name would leave one standing in for the other, and
+ * whichever the walk reached first would decide what a committed file is compared against.
+ */
+ private static Map generatedByName() throws IOException {
+ Map byName = new TreeMap<>();
+ try (var files = Files.walk(GeneratedCatalogue.ROOT)) {
+ files.filter(Files::isRegularFile).forEach(path -> {
+ Path clash = byName.put(path.getFileName().toString(), path);
+ if (clash != null) {
+ throw new IllegalStateException(
+ "two rendered documents share the name " + path.getFileName()
+ + " (" + clash + " and " + path + "); the committed folder is flat, so "
+ + "one would silently stand in for the other");
+ }
+ });
+ }
+ return byName;
+ }
+}
diff --git a/examples/src/test/java/com/demcha/examples/PptxCanonicalContentTest.java b/examples/src/test/java/com/demcha/examples/PptxCanonicalContentTest.java
deleted file mode 100644
index 15defbe5..00000000
--- a/examples/src/test/java/com/demcha/examples/PptxCanonicalContentTest.java
+++ /dev/null
@@ -1,513 +0,0 @@
-package com.demcha.examples;
-
-import com.demcha.examples.flagships.MavenBannerPptxExample;
-
-import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Test;
-
-import java.awt.image.BufferedImage;
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.nio.ByteBuffer;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.security.MessageDigest;
-import java.util.HexFormat;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeMap;
-import java.util.TreeSet;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipInputStream;
-
-import javax.imageio.ImageIO;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/**
- * Pins the canonical PPTX comparison the asset gate will be built on.
- *
- * A deck cannot be compared byte for byte. Every entry in the package carries the zip
- * timestamp of the run that wrote it, so two renders of an unchanged document differ. The
- * engine can pin those — both fixed backends take a {@code deterministic(...)} instant —
- * but {@code buildPptx(Path)} routes through the convenience path, which resolves the
- * backend from the provider and gives a caller no way to configure it. Until that seam
- * exists, a gate comparing bytes would either fail forever or drop the decks.
- *
- * Nor can it be compared part for part: three things about a package are decided by the
- * machine rather than by the document, and all three were measured on these decks rather
- * than assumed. See {@link #canonicalise(String, String, byte[])}. So the comparison is
- * defined here as the sorted package parts reduced to what the document decides — every
- * shape, relationship and run of text, with each freeform's path read where it lands on the
- * slide and every image read pixel for pixel bar the one part {@link #UNSTABLE_RASTERS}
- * names.
- *
- * What this class does not do is hold a committed asset against a fresh render. A
- * committed asset is rendered at the released version while a working tree renders the
- * next one, so the two are different documents by construction; and a committed asset is
- * not otherwise guaranteed to be level with the code that produces it. Comparing them
- * needs the display version as an input and a way to refresh whatever has drifted — one
- * job, belonging to the asset gate rather than to a unit test. The comparator is proven
- * here so that the gate arrives with it already working.
- */
-class PptxCanonicalContentTest {
-
- private static final Path COMMITTED = Path.of("..", "assets", "readme", "examples");
-
- @BeforeAll
- static void generateEveryExample() throws Exception {
- GeneratedCatalogue.generateOnce();
- }
-
- /**
- * The decks whose preview the repository commits.
- *
- * Which decks those are is a decision, and this list plus {@link #UNPUBLISHED_DECKS}
- * exist so that no deck can arrive without somebody making it. A subset alone is not
- * enough: asserting that the curated decks are among the generated ones lets a new
- * example ship a deck nobody has decided about, which is the same silence the pairing
- * this replaced used to keep. Together the two lists cover the catalogue exactly, so a
- * deck added, removed or renamed lands here first.
- */
- private static final Set CURATED_DECKS = Set.of(
- "business-report.pptx",
- "financial-report.pptx",
- "master-showcase.pptx",
- "maven-banner.pptx",
- "social-card.pptx",
- "twin-output.pptx");
-
- /** Decks the catalogue renders but the repository deliberately does not commit. */
- private static final Set UNPUBLISHED_DECKS = Set.of(
- "engine-deck.pptx",
- "linkedin-carousel.pptx");
-
- @Test
- void theTwoDeckListsDoNotOverlap() {
- assertThat(CURATED_DECKS)
- .describedAs("a deck cannot be both published and deliberately unpublished")
- .doesNotContainAnyElementsOf(UNPUBLISHED_DECKS);
- }
-
- @Test
- void theCommittedDecksAreExactlyTheCuratedSubset() throws Exception {
- Set committed = new TreeSet<>();
- try (var files = Files.list(COMMITTED)) {
- files.map(path -> path.getFileName().toString())
- .filter(name -> name.endsWith(".pptx"))
- .forEach(committed::add);
- }
- Map generated = new TreeMap<>();
- try (var decks = Files.walk(GeneratedCatalogue.ROOT)) {
- decks.filter(path -> path.toString().endsWith(".pptx"))
- .forEach(path -> {
- Path clash = generated.put(path.getFileName().toString(), path);
- if (clash != null) {
- throw new IllegalStateException(
- "two generated decks share the name " + path.getFileName()
- + " (" + clash + " and " + path + "); the committed gallery is "
- + "flat, so one would silently stand in for the other");
- }
- });
- }
-
- assertThat(committed)
- .describedAs("the committed decks and the curated list have to agree: a deck "
- + "added to the folder without a decision, or removed from it without "
- + "one, is exactly what this list exists to surface")
- .isEqualTo(new TreeSet<>(CURATED_DECKS));
- Set accountedFor = new TreeSet<>(CURATED_DECKS);
- accountedFor.addAll(UNPUBLISHED_DECKS);
- assertThat(generated.keySet())
- .describedAs("the catalogue and the two lists have to cover each other exactly: a "
- + "deck listed but not generated is a committed file nothing can refresh, "
- + "and a deck generated but on neither list is one nobody has decided to "
- + "publish or to leave out")
- .isEqualTo(accountedFor);
- }
-
- /**
- * Rendering the same deck twice produces one document under this digest.
- *
- * Only the equality is asserted. Whether the two files also differ in bytes depends
- * on the clock — zip entry timestamps have two-second granularity, so two renders in
- * quick succession can land on the same stamp and produce identical files, while two
- * a moment apart do not. That is the reason the gate cannot hash the file, and it is
- * also the reason it cannot be asserted: the property is real but intermittent, and a
- * test that pins it fails on whichever machine happens to be fast.
- */
- @Test
- void theSameDeckRenderedTwiceIsOneDocument() throws Exception {
- Path deck = GeneratedCatalogue.ROOT.resolve("flagships").resolve("maven-banner.pptx");
- assertThat(deck).exists();
-
- String firstDigest = canonicalDigest(deck);
- MavenBannerPptxExample.generate();
-
- assertThat(canonicalDigest(deck))
- .describedAs("the same document rendered twice must be one document")
- .isEqualTo(firstDigest);
- }
-
- /**
- * The same path drawn in a different box is one shape; a path that moves is not.
- *
- * Dropping the box is the load-bearing half of the comparison, so it is checked in
- * both directions: the three shapes below are the same freeform normalised the two ways
- * two machines normalised it, and then genuinely moved. Absorbing the third as well
- * would leave the guard reporting success on a deck whose artwork had shifted.
- */
- @Test
- void theBoxIsAbsorbedButAMoveIsNot() {
- String box = freeform("",
- "", "");
- String sameLineOtherBox = freeform(
- "",
- "", "");
- String movedLine = freeform("",
- "", "");
-
- assertThat(freeformsInSlideSpace(sameLineOtherBox))
- .describedAs("the same line from (1000,2000) to (1600,2400), normalised against "
- + "an origin 50 EMU away — the difference two machines produce")
- .isEqualTo(freeformsInSlideSpace(box));
- assertThat(freeformsInSlideSpace(movedLine))
- .describedAs("a line that actually moved by 50 EMU must not be absorbed")
- .isNotEqualTo(freeformsInSlideSpace(box));
- }
-
- /**
- * The rewrite reaches the markup POI actually writes, not just the shape of it.
- *
- * {@link #theBoxIsAbsorbedButAMoveIsNot} works on markup written here, so it would
- * still pass if POI reordered an attribute and the rewrite quietly stopped matching —
- * leaving a comparison that absorbs nothing and a red gate nobody can read. A deck the
- * repository commits has to come out changed.
- */
- @Test
- void theRewriteReachesRealMarkup() throws Exception {
- String slide = part(COMMITTED.resolve("twin-output.pptx"), "ppt/slides/slide1.xml");
-
- assertThat(freeformsInSlideSpace(slide))
- .describedAs("no freeform in a deck full of them was rewritten — the markup no "
- + "longer looks the way the patterns expect")
- .isNotEqualTo(slide);
- }
-
- /**
- * An image of the same size is not the same image.
- *
- * Reading a raster part by its dimensions would let a logo be swapped, a screenshot
- * be replaced or a watermark be retyped without the comparison noticing, which is a
- * quiet way for a gate to report that nothing changed. Only the one part measured as
- * machine-dependent is read that way; the fixtures below are the same watermark from
- * two machines, so they are the closest two images this repository has, and even they
- * must come out different when compared as images.
- */
- @Test
- void sameDimensionsButDifferentPixelsAreNotEqual() throws Exception {
- BufferedImage windows = decode(WATERMARK_WINDOWS);
- BufferedImage linux = decode(WATERMARK_LINUX);
- assertThat(windows.getWidth()).isEqualTo(linux.getWidth());
- assertThat(windows.getHeight()).isEqualTo(linux.getHeight());
-
- assertThat(canonicalise("business-report.pptx", WATERMARK_PART, fixture(WATERMARK_WINDOWS)))
- .describedAs("two images of one size are not one image — and the deck this part "
- + "sits in is not the one the allowlist names")
- .isNotEqualTo(canonicalise("business-report.pptx", WATERMARK_PART,
- fixture(WATERMARK_LINUX)));
- }
-
- /**
- * The named part absorbs the difference that was measured, and the fixtures carry it.
- *
- * The two files are {@code ppt/media/image1.png} of the showcase deck rendered on
- * Windows and on the runner: same glyphs in the same places, different coverage along
- * their edges. Pinning them here keeps the allowlist honest — if the difference ever
- * stops being antialiasing the entry stops being justified, and this is where that
- * shows.
- */
- @Test
- void theNamedUnstableRasterAbsorbsTheMeasuredDifference() throws Exception {
- byte[] windows = fixture(WATERMARK_WINDOWS);
- byte[] linux = fixture(WATERMARK_LINUX);
- assertThat(windows)
- .describedAs("the fixtures have to be two renders, not one file twice")
- .isNotEqualTo(linux);
-
- assertThat(canonicalise(WATERMARK_DECK, WATERMARK_PART, windows))
- .describedAs("the watermark named in the allowlist is the one difference the "
- + "comparison is allowed to pass over")
- .isEqualTo(canonicalise(WATERMARK_DECK, WATERMARK_PART, linux));
- }
-
- /**
- * A part written with either line ending is one part.
- *
- * The normalisation this pins is the difference that failed six decks on the runner,
- * and it is the one canonicalisation whose absence shows up nowhere else: every test
- * here runs on one machine, where both sides carry the same line ending and the bug
- * hides.
- */
- @Test
- void theSamePartWithEitherLineEndingIsOnePart() throws Exception {
- String declaration = "";
- byte[] unix = (declaration + "\n").getBytes(StandardCharsets.UTF_8);
- byte[] windows = (declaration + "\r\n").getBytes(StandardCharsets.UTF_8);
-
- assertThat(canonicalise(WATERMARK_DECK, "ppt/slides/slide1.xml", windows))
- .describedAs("POI ends the XML declaration with the platform's line separator; "
- + "that is the machine writing, not the document")
- .isEqualTo(canonicalise(WATERMARK_DECK, "ppt/slides/slide1.xml", unix));
- }
-
- /**
- * Every allowlisted part names a part that is really there.
- *
- * A misspelled deck or part would match nothing, and an entry that matches nothing
- * exempts nothing — the comparison would quietly go back to reading the watermark by
- * its pixels and fail on a runner for a reason the entry was added to explain.
- */
- @Test
- void everyAllowlistedRasterExists() throws Exception {
- for (String entry : new TreeSet<>(UNSTABLE_RASTERS)) {
- String[] split = entry.split("!", 2);
- assertThat(split).describedAs("%s is not deck!part", entry).hasSize(2);
- for (Path deck : List.of(COMMITTED.resolve(split[0]), generatedDeck(split[0]))) {
- assertThat(deck).describedAs("allowlisted deck %s is missing", split[0]).exists();
- assertThat(partNames(deck))
- .describedAs("allowlisted part %s is not in %s", split[1], deck)
- .contains(split[1]);
- }
- }
- }
-
- private static Path generatedDeck(String name) throws IOException {
- try (var decks = Files.walk(GeneratedCatalogue.ROOT)) {
- return decks.filter(path -> path.getFileName().toString().equals(name))
- .findFirst()
- .orElseThrow(() -> new IllegalStateException(
- "the catalogue no longer renders " + name));
- }
- }
-
- private static Set partNames(Path pptx) throws IOException {
- Set names = new TreeSet<>();
- try (ZipInputStream zip =
- new ZipInputStream(new ByteArrayInputStream(Files.readAllBytes(pptx)))) {
- for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
- names.add(entry.getName());
- }
- }
- return names;
- }
-
- private static final String WATERMARK_WINDOWS = "watermark-windows.png";
- private static final String WATERMARK_LINUX = "watermark-linux.png";
- private static final String WATERMARK_DECK = "master-showcase.pptx";
- private static final String WATERMARK_PART = "ppt/media/image1.png";
-
- private static byte[] fixture(String name) throws IOException {
- try (var in = PptxCanonicalContentTest.class.getResourceAsStream("/pptx-media/" + name)) {
- assertThat(in).describedAs("missing test fixture %s", name).isNotNull();
- return in.readAllBytes();
- }
- }
-
- private static BufferedImage decode(String name) throws IOException {
- BufferedImage image = ImageIO.read(new ByteArrayInputStream(fixture(name)));
- assertThat(image).describedAs("fixture %s does not decode as an image", name).isNotNull();
- return image;
- }
-
- private static String part(Path pptx, String name) throws Exception {
- try (ZipInputStream zip =
- new ZipInputStream(new ByteArrayInputStream(Files.readAllBytes(pptx)))) {
- for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
- if (entry.getName().equals(name)) {
- return new String(zip.readAllBytes(), StandardCharsets.UTF_8)
- .replace("\r\n", "\n");
- }
- }
- }
- throw new IllegalStateException(pptx + " has no part " + name);
- }
-
- private static String freeform(String xfrm, String from, String to) {
- return ""
- + "" + xfrm + ""
- + ""
- + "" + from + "" + to + ""
- + "";
- }
-
- /** The package's parts and their contents, with everything the machine adds left out. */
- private static String canonicalDigest(Path pptx) throws Exception {
- Map parts = new TreeMap<>();
- try (ZipInputStream zip =
- new ZipInputStream(new ByteArrayInputStream(Files.readAllBytes(pptx)))) {
- String deck = pptx.getFileName().toString();
- for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
- if (!entry.isDirectory()) {
- parts.put(entry.getName(),
- canonicalise(deck, entry.getName(), zip.readAllBytes()));
- }
- }
- }
-
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
- parts.forEach((name, content) -> {
- digest.update(name.getBytes(StandardCharsets.UTF_8));
- digest.update((byte) 0);
- digest.update(content);
- digest.update((byte) 0);
- });
- return HexFormat.of().formatHex(digest.digest());
- }
-
- /**
- * Reduces a part to what the document decides, dropping what the machine decides.
- *
- * Three differences were measured between decks written on Windows and the same
- * decks written on the Linux runner, none of them a change to the document:
- *
- *
- * - Line endings. POI ends the XML declaration with the platform's line
- * separator, so every XML part differs by one byte — sixteen parts of an
- * otherwise untouched deck.
- * - Freeform boxes. The box a freeform declares, and the origin its path
- * coordinates are measured from, are not stable: one icon came out with every
- * point shifted by a constant 272 EMU across and 489 down, and its declared
- * extent smaller by the same amount, so that the path landed on exactly the same
- * place on the slide. The points are therefore compared where they land, and the
- * box that only says how they were normalised is dropped. A freeform that
- * actually moves still moves its points.
- * - Rasterised text. One embedded image — a text watermark — differed along
- * the glyph edges alone: same glyphs, same positions, different antialiasing
- * coverage. Nothing canonicalises that, so {@link #UNSTABLE_RASTERS} names the
- * part and it alone is read by its dimensions. Every other image is compared
- * pixel for pixel.
- *
- */
- private static byte[] canonicalise(String deck, String name, byte[] content)
- throws IOException {
- if (name.startsWith("ppt/media/")) {
- return image(content, UNSTABLE_RASTERS.contains(deck + "!" + name));
- }
- if (!name.endsWith(".xml") && !name.endsWith(".rels")) {
- return content;
- }
- String text = new String(content, StandardCharsets.UTF_8).replace("\r\n", "\n");
- return freeformsInSlideSpace(text).getBytes(StandardCharsets.UTF_8);
- }
-
- /**
- * The one raster part a machine is allowed to disagree about, as {@code deck!part}.
- *
- * Naming it costs a line and buys the difference between a comparison that tolerates
- * one measured artefact and one that stops reading images altogether. Anything added
- * here stops being compared by content, so it wants the same measurement behind it that
- * put this entry here.
- */
- private static final Set UNSTABLE_RASTERS =
- Set.of(WATERMARK_DECK + "!" + WATERMARK_PART);
-
- /**
- * An image's pixels in one colour model, or — for a named unstable part — its size.
- *
- * {@code getRGB} converts whatever the decoder produced into sRGB, so a part is
- * compared by what it looks like rather than by how it was stored. A part that decodes
- * to nothing is vector, and its bytes are its content.
- */
- private static byte[] image(byte[] content, boolean sizeOnly) throws IOException {
- BufferedImage image = ImageIO.read(new ByteArrayInputStream(content));
- if (image == null) {
- return content;
- }
- int width = image.getWidth();
- int height = image.getHeight();
- if (sizeOnly) {
- return "%dx%d antialiasing not compared".formatted(width, height)
- .getBytes(StandardCharsets.UTF_8);
- }
- ByteBuffer pixels = ByteBuffer.allocate(8 + 4 * width * height);
- pixels.putInt(width).putInt(height);
- for (int argb : image.getRGB(0, 0, width, height, null, 0, width)) {
- pixels.putInt(argb);
- }
- return pixels.array();
- }
-
- private static final Pattern SHAPE = Pattern.compile(".*?", Pattern.DOTALL);
- private static final Pattern OFFSET = Pattern.compile("");
- private static final Pattern EXTENT = Pattern.compile("");
- private static final Pattern PATH_TAG = Pattern.compile("]*)>");
- private static final Pattern POINT = Pattern.compile("");
- private static final Pattern BOX_ATTRIBUTE = Pattern.compile(" [wh]=\"\\d+\"");
- private static final Pattern PATH_WIDTH = Pattern.compile("\\bw=\"(\\d+)\"");
- private static final Pattern PATH_HEIGHT = Pattern.compile("\\bh=\"(\\d+)\"");
-
- /** Reads each freeform's path where it lands on the slide rather than inside its box. */
- private static String freeformsInSlideSpace(String xml) {
- Matcher shapes = SHAPE.matcher(xml);
- StringBuilder out = new StringBuilder();
- while (shapes.find()) {
- shapes.appendReplacement(out, Matcher.quoteReplacement(inSlideSpace(shapes.group())));
- }
- shapes.appendTail(out);
- return out.toString();
- }
-
- private static String inSlideSpace(String shape) {
- Matcher offset = OFFSET.matcher(shape);
- Matcher extent = EXTENT.matcher(shape);
- Matcher pathTag = PATH_TAG.matcher(shape);
- if (!offset.find() || !extent.find() || !pathTag.find()) {
- return shape;
- }
- long pathWidth = attribute(PATH_WIDTH, pathTag.group(1));
- long pathHeight = attribute(PATH_HEIGHT, pathTag.group(1));
- if (pathWidth <= 0 || pathHeight <= 0) {
- return shape;
- }
- long offsetX = Long.parseLong(offset.group(1));
- long offsetY = Long.parseLong(offset.group(2));
- double scaleX = Long.parseLong(extent.group(1)) / (double) pathWidth;
- double scaleY = Long.parseLong(extent.group(2)) / (double) pathHeight;
-
- Matcher points = POINT.matcher(shape);
- StringBuilder out = new StringBuilder();
- while (points.find()) {
- long x = offsetX + Math.round(Long.parseLong(points.group(1)) * scaleX);
- long y = offsetY + Math.round(Long.parseLong(points.group(2)) * scaleY);
- points.appendReplacement(out,
- Matcher.quoteReplacement("".formatted(x, y)));
- }
- points.appendTail(out);
-
- String slideSpace = OFFSET.matcher(out.toString()).replaceFirst("");
- slideSpace = EXTENT.matcher(slideSpace).replaceFirst("");
- return withoutPathBox(slideSpace);
- }
-
- private static String withoutPathBox(String shape) {
- Matcher tags = PATH_TAG.matcher(shape);
- StringBuilder out = new StringBuilder();
- while (tags.find()) {
- String rest = BOX_ATTRIBUTE.matcher(" " + tags.group(1)).replaceAll("").trim();
- tags.appendReplacement(out, Matcher.quoteReplacement(
- rest.isEmpty() ? "" : ""));
- }
- tags.appendTail(out);
- return out.toString();
- }
-
- private static long attribute(Pattern attribute, String attributes) {
- Matcher value = attribute.matcher(attributes);
- return value.find() ? Long.parseLong(value.group(1)) : -1;
- }
-}
diff --git a/examples/src/test/java/com/demcha/examples/support/AssetContent.java b/examples/src/test/java/com/demcha/examples/support/AssetContent.java
new file mode 100644
index 00000000..8811163d
--- /dev/null
+++ b/examples/src/test/java/com/demcha/examples/support/AssetContent.java
@@ -0,0 +1,284 @@
+package com.demcha.examples.support;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import javax.imageio.ImageIO;
+
+/**
+ * What a rendered document can be compared by, once the machine is left out of it.
+ *
+ * Two renders of an unchanged document are not the same file. A PDF carries a clock-seeded
+ * {@code /ID}; an OOXML package carries a zip timestamp on every entry and a creation stamp in
+ * its properties; POI ends each XML declaration with the platform's line separator. None of
+ * that is the document, and a comparison that reads it reports a change on every run.
+ *
+ * Everything this drops was measured on this repository's own catalogue rather than assumed,
+ * by rendering it on Windows and on the Linux runner at the same version: of 104 documents, 99
+ * came out byte-identical under the reduction below. The five that did not are accounted for by
+ * {@link #UNSTABLE_PARTS} and {@link #UNSTABLE_SHAPES} or are decks the repository does not
+ * commit. So the comparison stays exact: no tolerance, no sampling, and every exemption names the
+ * one thing it covers and the two renders it covers it between — never a rule applied to
+ * everything of that kind.
+ */
+public final class AssetContent {
+
+ private AssetContent() {
+ }
+
+ /**
+ * The parts a machine is allowed to disagree about, and the renders it may disagree between.
+ *
+ * One entry, and it earns itself: the showcase deck embeds an image of a text watermark,
+ * and the same glyphs in the same places came out with different antialiasing coverage along
+ * their edges on the two machines. Averaging it away is not available — 4-pixel blocks still
+ * differ by 138 of 255.
+ *
+ * What is exempted is not the part but the pair of renders. Each value is the pixel digest
+ * of one machine's version of that part; those two collapse to one token, and anything else —
+ * a different watermark, a swapped logo, an empty image of the same size — keeps its own
+ * digest and fails the comparison. Adding a key here without its digests would exempt the
+ * part itself, which is the hole this shape exists to close.
+ */
+ public static final Map> UNSTABLE_PARTS = Map.of(
+ "master-showcase.pptx!ppt/media/image1.png",
+ Set.of("1f629c6a16dd2d5c18ead1594788ce04e0341360f57af68421b428d56cfb03a8",
+ "9b4a3b3d0dcae564393372bccb71430b0367b6c189e36bfff664a4a67f515225"));
+
+ /**
+ * The shapes a machine is allowed to disagree about, as {@code document!part!shape}.
+ *
+ * Two, and both in one deck: the box a freeform declares, and the origin its points are
+ * measured from, came out differently on the two machines — one icon with every point shifted
+ * by a constant 272 EMU across and 489 down and its extent smaller by exactly as much, so the
+ * path landed on the same place on the slide to the unit. Comparing all six committed decks
+ * shape by shape found these two and nothing else.
+ *
+ * Like {@link #UNSTABLE_PARTS}, the value is the digest of each machine's version, so the
+ * exemption is for the pair of renders rather than for the shape: change either one and it
+ * stops being absorbed. Every other shape in every deck is compared as it was written,
+ * including the box — which is not decoration, but the centre a {@code rot} turns a shape
+ * around and the axis a {@code flipH} mirrors it in.
+ */
+ public static final Map> UNSTABLE_SHAPES = Map.of(
+ "twin-output.pptx!ppt/slides/slide1.xml!Freeform 41",
+ Set.of("18dacede01e07a4408f19b8f2a6fc072887e6d3f5796064db4caf1b933a0f741",
+ "4d126750bda49ababc68b4020984bc1295d7e94b02dd2629bc3d971d05346656"),
+ "twin-output.pptx!ppt/slides/slide1.xml!Freeform 51",
+ Set.of("c59aeb5cc0676a9dc35e01aff745d0b2d66500ab7dfca4afb24d4f4a60c15262",
+ "33904bc4a82c84c86384832ca4b6c9cad738bcca558d756a43d25b9676e1b65e"));
+
+ /** Extensions this can reduce; anything else is compared as the bytes it is. */
+ private static final Set PACKAGES = Set.of(".pptx", ".docx");
+
+ private static final Pattern PDF_ID =
+ Pattern.compile("/ID \\[<[0-9A-Fa-f]+> <[0-9A-Fa-f]+>\\]");
+ /**
+ * The one element in a package's properties that records when it was written.
+ *
+ * Named rather than matched by shape: a pattern for "any ISO instant under
+ * {@code docProps}" would also drop a date somebody meant, and a document whose custom
+ * property is a date has every right to be compared by it.
+ */
+ private static final Pattern CREATION_STAMP =
+ Pattern.compile("(]*>)[^<]*()");
+
+ /**
+ * A stable identity for a rendered document.
+ *
+ * Two documents with the same digest are the same document; two with different digests
+ * differ in something an author wrote. The name matters — it selects the reduction, and for
+ * a package it also selects any {@link #UNSTABLE_PARTS} entry — so pass the name the file is
+ * committed under, not a temporary one.
+ *
+ * @param document the file to read
+ * @return a hex SHA-256 over the document's content
+ * @throws IOException if the file cannot be read
+ */
+ public static String digestOf(Path document) throws IOException {
+ String name = document.getFileName().toString();
+ byte[] bytes = Files.readAllBytes(document);
+ if (PACKAGES.stream().anyMatch(name::endsWith)) {
+ return digest(packageParts(name, bytes));
+ }
+ if (name.endsWith(".pdf")) {
+ return digest(Map.of(name, withoutPdfId(bytes)));
+ }
+ return digest(Map.of(name, bytes));
+ }
+
+ /** The zip's parts, each reduced, with the entry order and timestamps dropped. */
+ private static Map packageParts(String document, byte[] bytes)
+ throws IOException {
+ Map parts = new TreeMap<>();
+ try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(bytes))) {
+ for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
+ if (!entry.isDirectory()) {
+ parts.put(entry.getName(),
+ part(document, entry.getName(), zip.readAllBytes()));
+ }
+ }
+ }
+ return parts;
+ }
+
+ /**
+ * Reduces one part of a package to what the document decides.
+ *
+ * Three differences were measured between a package written on Windows and the same
+ * package written on the runner, none of them a change to the document: the platform's line
+ * separator after each XML declaration, the creation stamp in {@code docProps}, and two
+ * freeform shapes in one deck. The first two are dropped wherever they appear; the third is
+ * dropped only for those two shapes — see {@link #UNSTABLE_SHAPES}.
+ */
+ static byte[] part(String document, String name, byte[] content) throws IOException {
+ if (name.startsWith("ppt/media/") || name.startsWith("word/media/")) {
+ return raster(document + "!" + name, content);
+ }
+ if (!name.endsWith(".xml") && !name.endsWith(".rels")) {
+ return content;
+ }
+ String text = new String(content, StandardCharsets.UTF_8).replace("\r\n", "\n");
+ if (name.equals("docProps/core.xml")) {
+ text = CREATION_STAMP.matcher(text).replaceAll("$1$2");
+ }
+ return knownShapesCollapsed(document + "!" + name, text)
+ .getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * A raster part read by its pixels, unless it is one of a pair a machine writes differently.
+ *
+ * An exempted part is not waved through: its pixels still decide, and only the two renders
+ * written down in {@link #UNSTABLE_PARTS} collapse onto one token. Anything else keeps the
+ * digest of what it actually is, so an image swapped for another of the same size differs
+ * from both the token and the other machine's render.
+ */
+ private static byte[] raster(String key, byte[] content) throws IOException {
+ String pixels = pixelDigest(content);
+ if (pixels == null) {
+ return content;
+ }
+ if (UNSTABLE_PARTS.getOrDefault(key, Set.of()).contains(pixels)) {
+ return ("a known render of " + key).getBytes(StandardCharsets.UTF_8);
+ }
+ return pixels.getBytes(StandardCharsets.UTF_8);
+ }
+
+ /** A PDF with the one thing in it that the clock writes taken out. */
+ static byte[] withoutPdfId(byte[] content) {
+ String text = new String(content, StandardCharsets.ISO_8859_1);
+ return PDF_ID.matcher(text).replaceAll("/ID []").getBytes(StandardCharsets.ISO_8859_1);
+ }
+
+ /**
+ * A digest of an image's size and pixels in one colour model, or {@code null} if it is vector.
+ *
+ * {@code getRGB} converts whatever the decoder produced into sRGB, so an image is compared
+ * by what it looks like rather than by how it was stored. The pixels are read a row at a time
+ * and folded into the digest rather than buffered: an embedded screenshot runs to megapixels,
+ * and holding two of those as byte arrays to compare them is a waste of a test's heap.
+ *
+ * @param content the bytes of the part
+ * @return a hex SHA-256 over the image, or {@code null} when nothing decodes it
+ * @throws IOException if the bytes cannot be read
+ */
+ public static String pixelDigest(byte[] content) throws IOException {
+ BufferedImage image = ImageIO.read(new ByteArrayInputStream(content));
+ if (image == null) {
+ return null;
+ }
+ int width = image.getWidth();
+ int height = image.getHeight();
+ ByteBuffer row = ByteBuffer.allocate(4 * width);
+ MessageDigest digest = sha256();
+ digest.update("%dx%d".formatted(width, height).getBytes(StandardCharsets.UTF_8));
+ int[] pixels = new int[width];
+ for (int y = 0; y < height; y++) {
+ image.getRGB(0, y, width, 1, pixels, 0, width);
+ row.clear();
+ for (int argb : pixels) {
+ row.putInt(argb);
+ }
+ digest.update(row.array());
+ }
+ return HexFormat.of().formatHex(digest.digest());
+ }
+
+ private static final Pattern SHAPE = Pattern.compile(".*?", Pattern.DOTALL);
+ private static final Pattern SHAPE_NAME = Pattern.compile("An earlier version of this rewrote every freeform: it read the path in slide
+ * coordinates and dropped the box those coordinates were measured against. That absorbed the
+ * measured difference and a good deal more — the box a shape declares is also the centre a
+ * {@code rot} turns it around and the axis a {@code flipH} mirrors it in, and five of the six
+ * committed decks carry one or the other. Two shapes drawn around different centres would
+ * have compared equal while PowerPoint drew them differently.
+ *
+ * What was actually measured is two shapes in one deck, so two shapes in one deck are what
+ * is exempted — by the digest of each machine's version of them, the way an unstable image
+ * is. Every other shape is compared as it was written.
+ */
+ static String knownShapesCollapsed(String part, String xml) {
+ if (UNSTABLE_SHAPES.keySet().stream().noneMatch(key -> key.startsWith(part + "!"))) {
+ return xml;
+ }
+ Matcher shapes = SHAPE.matcher(xml);
+ StringBuilder out = new StringBuilder();
+ while (shapes.find()) {
+ shapes.appendReplacement(out, Matcher.quoteReplacement(collapse(part, shapes.group())));
+ }
+ shapes.appendTail(out);
+ return out.toString();
+ }
+
+ private static String collapse(String part, String shape) {
+ Matcher name = SHAPE_NAME.matcher(shape);
+ if (!name.find()) {
+ return shape;
+ }
+ String key = part + "!" + name.group(1);
+ String digest = HexFormat.of().formatHex(
+ sha256().digest(shape.getBytes(StandardCharsets.UTF_8)));
+ return UNSTABLE_SHAPES.getOrDefault(key, Set.of()).contains(digest)
+ ? ""
+ : shape;
+ }
+
+ private static MessageDigest sha256() {
+ try {
+ return MessageDigest.getInstance("SHA-256");
+ } catch (NoSuchAlgorithmException impossible) {
+ throw new IllegalStateException("SHA-256 is required of every JVM", impossible);
+ }
+ }
+
+ private static String digest(Map parts) {
+ MessageDigest digest = sha256();
+ new TreeMap<>(parts).forEach((name, content) -> {
+ digest.update(name.getBytes(StandardCharsets.UTF_8));
+ digest.update((byte) 0);
+ digest.update(content);
+ digest.update((byte) 0);
+ });
+ return HexFormat.of().formatHex(digest.digest());
+ }
+}
diff --git a/examples/src/test/java/com/demcha/examples/support/AssetContentTest.java b/examples/src/test/java/com/demcha/examples/support/AssetContentTest.java
new file mode 100644
index 00000000..362a356c
--- /dev/null
+++ b/examples/src/test/java/com/demcha/examples/support/AssetContentTest.java
@@ -0,0 +1,385 @@
+package com.demcha.examples.support;
+
+import com.demcha.examples.GeneratedCatalogue;
+import com.demcha.examples.flagships.MavenBannerPptxExample;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import javax.imageio.ImageIO;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Proves the comparison drops what the machine writes and nothing else.
+ *
+ * {@link AssetContent} is what the asset gate reads a document through, so each reduction it
+ * makes is checked in both directions: the difference it is meant to absorb comes out equal, and
+ * the change it must never absorb comes out different. A reduction that quietly stops matching —
+ * a reordered attribute, a mistyped part name — would leave a gate that compares nothing and
+ * passes everything, so the ones driven by patterns are also run against markup POI really
+ * wrote.
+ */
+class AssetContentTest {
+
+ private static final Path COMMITTED = Path.of("..", "assets", "readme", "examples");
+ private static final String WATERMARK_WINDOWS = "watermark-windows.png";
+ private static final String WATERMARK_LINUX = "watermark-linux.png";
+ private static final String WATERMARK_DECK = "master-showcase.pptx";
+ private static final String WATERMARK_PART = "ppt/media/image1.png";
+ private static final String TWIN_SLIDE = "twin-output.pptx!ppt/slides/slide1.xml";
+
+ @BeforeAll
+ static void generateEveryExample() throws Exception {
+ GeneratedCatalogue.generateOnce();
+ }
+
+ /**
+ * Rendering the same deck twice produces one document under this comparison.
+ *
+ * Only the equality is asserted. Whether the two files also differ in bytes depends on the
+ * clock — zip entry timestamps have two-second granularity, so two renders in quick
+ * succession can land on the same stamp and produce identical files, while two a moment apart
+ * do not. That is the reason the gate cannot hash the file, and it is also the reason it
+ * cannot be asserted: the property is real but intermittent, and a test that pins it fails on
+ * whichever machine happens to be fast.
+ */
+ @Test
+ void theSameDeckRenderedTwiceIsOneDocument() throws Exception {
+ Path deck = GeneratedCatalogue.ROOT.resolve("flagships").resolve("maven-banner.pptx");
+ assertThat(deck).exists();
+
+ String first = AssetContent.digestOf(deck);
+ MavenBannerPptxExample.generate();
+
+ assertThat(AssetContent.digestOf(deck))
+ .describedAs("the same document rendered twice must be one document")
+ .isEqualTo(first);
+ }
+
+ /** A PDF that differs only in the identifier the clock seeds is one document. */
+ @Test
+ void aPdfCarryingADifferentIdentifierIsTheSameDocument() throws Exception {
+ Path pdf = GeneratedCatalogue.ROOT.resolve("flagships").resolve("maven-banner.pdf");
+ assertThat(pdf).exists();
+ byte[] rendered = Files.readAllBytes(pdf);
+ byte[] reseeded = new String(rendered, StandardCharsets.ISO_8859_1)
+ .replaceFirst("/ID \\[<[0-9A-Fa-f]+> <[0-9A-Fa-f]+>\\]",
+ "/ID [<" + "A".repeat(64) + "> <" + "A".repeat(64) + ">]")
+ .getBytes(StandardCharsets.ISO_8859_1);
+
+ assertThat(reseeded)
+ .describedAs("the fixture must actually carry an /ID for this to prove anything")
+ .isNotEqualTo(rendered);
+ assertThat(AssetContent.withoutPdfId(reseeded))
+ .describedAs("a fresh /ID is the clock writing, not the document")
+ .isEqualTo(AssetContent.withoutPdfId(rendered));
+ }
+
+ /**
+ * Two shapes drawn around different centres are two shapes.
+ *
+ * An earlier comparison read every freeform's path in slide coordinates and dropped the box
+ * those coordinates were measured against. The paths below land in the same place and turn by
+ * the same angle, and they are still different pictures: {@code rot} turns a shape around the
+ * centre of its box, so a 100-wide box and a 200-wide one spin the same line about different
+ * points. Nothing may collapse them.
+ */
+ @Test
+ void rotatedFreeformsWithDifferentPivotsAreNotEqual() {
+ String narrow = rotatedFreeform("");
+ String wide = rotatedFreeform("");
+
+ assertThat(AssetContent.knownShapesCollapsed(TWIN_SLIDE, wide))
+ .describedAs("the same line turned 90 degrees about a different centre is not the "
+ + "same picture, however the points are written")
+ .isNotEqualTo(AssetContent.knownShapesCollapsed(TWIN_SLIDE, narrow));
+ }
+
+ /**
+ * The two shapes the allowlist names collapse; a shape it does not name is untouched.
+ *
+ * What was measured is two freeforms in one deck, so two freeforms in one deck are what is
+ * exempted. A shape reaching this with any other name — or either of these two after somebody
+ * changed it — comes out of the comparison exactly as it went in.
+ */
+ @Test
+ void onlyTheNamedShapesCollapse() throws Exception {
+ String slide = part(COMMITTED.resolve("twin-output.pptx"), "ppt/slides/slide1.xml");
+ String collapsed = AssetContent.knownShapesCollapsed(TWIN_SLIDE, slide);
+
+ assertThat(collapsed)
+ .describedAs("the two measured shapes have to be recognised in the deck they were "
+ + "measured in, or the exemption describes nothing")
+ .contains("Freeform 41/>", "Freeform 51/>");
+ assertThat(collapsed)
+ .describedAs("a shape the allowlist does not name is compared as it was written")
+ .contains("The digests are of what two machines wrote at the time they were measured. If the example
+ * moves either shape, both sides move together and the pair silently stops being exempt — the
+ * comparison would go back to reading them and fail on a runner for a reason the entry was
+ * added to explain.
+ */
+ @Test
+ void theNamedShapesAreStillWhatBothDecksCarry() throws Exception {
+ for (Path deck : List.of(COMMITTED.resolve("twin-output.pptx"),
+ generated("twin-output.pptx"))) {
+ String slide = part(deck, "ppt/slides/slide1.xml");
+ assertThat(AssetContent.knownShapesCollapsed(TWIN_SLIDE, slide))
+ .describedAs("%s no longer carries the shapes the allowlist was measured on",
+ deck)
+ .contains("Freeform 41/>", "Freeform 51/>");
+ }
+ }
+
+ /**
+ * An image of the same size is not the same image.
+ *
+ * Reading a raster part by its dimensions would let a logo be swapped, a screenshot be
+ * replaced or a watermark be retyped without the comparison noticing, which is a quiet way
+ * for a gate to report that nothing changed. Only the parts named in
+ * {@link AssetContent#UNSTABLE_PARTS} are read that way; the fixtures are the same watermark
+ * from two machines, so they are the closest two images this repository has, and even they
+ * must come out different when compared as images.
+ */
+ @Test
+ void sameDimensionsButDifferentPixelsAreNotEqual() throws Exception {
+ BufferedImage windows = decode(WATERMARK_WINDOWS);
+ BufferedImage linux = decode(WATERMARK_LINUX);
+ assertThat(windows.getWidth()).isEqualTo(linux.getWidth());
+ assertThat(windows.getHeight()).isEqualTo(linux.getHeight());
+
+ assertThat(AssetContent.part("business-report.pptx", WATERMARK_PART,
+ fixture(WATERMARK_WINDOWS)))
+ .describedAs("two images of one size are not one image — and the deck this part "
+ + "sits in is not one the allowlist names")
+ .isNotEqualTo(AssetContent.part("business-report.pptx", WATERMARK_PART,
+ fixture(WATERMARK_LINUX)));
+ }
+
+ /**
+ * The named part absorbs the difference that was measured, and the fixtures carry it.
+ *
+ * The two files are {@code ppt/media/image1.png} of the showcase deck rendered on Windows
+ * and on the runner: same glyphs in the same places, different coverage along their edges.
+ * Pinning them here keeps the allowlist honest — if the difference ever stops being
+ * antialiasing the entry stops being justified, and this is where that shows.
+ */
+ @Test
+ void theNamedUnstablePartAbsorbsTheMeasuredDifference() throws Exception {
+ byte[] windows = fixture(WATERMARK_WINDOWS);
+ byte[] linux = fixture(WATERMARK_LINUX);
+ assertThat(windows)
+ .describedAs("the fixtures have to be two renders, not one file twice")
+ .isNotEqualTo(linux);
+
+ assertThat(AssetContent.part(WATERMARK_DECK, WATERMARK_PART, windows))
+ .describedAs("the watermark named in the allowlist is the one difference the "
+ + "comparison is allowed to pass over")
+ .isEqualTo(AssetContent.part(WATERMARK_DECK, WATERMARK_PART, linux));
+ }
+
+ /**
+ * The exemption is for two known renders, not for whatever occupies that path.
+ *
+ * Exempting the part itself would let the watermark be swapped for another image of the
+ * same size — a different word, a logo, a blank — and the comparison would report a deck
+ * unchanged. Only the two digests written down collapse; a third image keeps its own.
+ */
+ @Test
+ void anUnknownImageOfTheSameSizeIsNotAbsorbed() throws Exception {
+ BufferedImage watermark = decode(WATERMARK_WINDOWS);
+ byte[] blank = blankPng(watermark.getWidth(), watermark.getHeight());
+ assertThat(AssetContent.pixelDigest(blank))
+ .describedAs("the substitute must decode, or this proves nothing")
+ .isNotNull();
+
+ assertThat(AssetContent.part(WATERMARK_DECK, WATERMARK_PART, blank))
+ .describedAs("an image the allowlist has never seen is compared by its pixels, "
+ + "however well it matches the size of the one that is exempt")
+ .isNotEqualTo(AssetContent.part(WATERMARK_DECK, WATERMARK_PART,
+ fixture(WATERMARK_WINDOWS)));
+ }
+
+ /**
+ * The part the allowlist exempts is still the watermark that was measured.
+ *
+ * Naming a path and pinning two digests protects nothing if the deck stopped carrying
+ * either of them: the entry would silently become an exemption for an image nobody has
+ * looked at. Both the committed deck and the one the catalogue renders have to hold a
+ * version the fixtures account for.
+ */
+ @Test
+ void theAllowlistedPartInBothDecksIsAKnownRender() throws Exception {
+ Set known = AssetContent.UNSTABLE_PARTS.get(WATERMARK_DECK + "!" + WATERMARK_PART);
+ assertThat(known).describedAs("the allowlist entry has no digests").isNotEmpty();
+
+ for (Path deck : List.of(COMMITTED.resolve(WATERMARK_DECK), generated(WATERMARK_DECK))) {
+ assertThat(AssetContent.pixelDigest(bytesOfPart(deck, WATERMARK_PART)))
+ .describedAs("%s carries a watermark neither fixture accounts for — the "
+ + "exemption no longer describes what it exempts", deck)
+ .isIn(known);
+ }
+ }
+
+ /** A date somebody wrote is not a date the machine wrote. */
+ @Test
+ void aCustomDatePropertyIsNotAbsorbed() throws Exception {
+ String properties = "%s"
+ + "";
+ byte[] earlier = properties.formatted("2026-01-01T00:00:00Z")
+ .getBytes(StandardCharsets.UTF_8);
+ byte[] later = properties.formatted("2026-08-02T00:00:00Z")
+ .getBytes(StandardCharsets.UTF_8);
+
+ assertThat(AssetContent.part("word-export-companion.docx", "docProps/custom.xml", earlier))
+ .describedAs("only the element that records when the package was written is "
+ + "dropped; a date the document states is the document's")
+ .isNotEqualTo(AssetContent.part("word-export-companion.docx",
+ "docProps/custom.xml", later));
+ }
+
+ /**
+ * A part written with either line ending is one part.
+ *
+ * This is the reduction that failed six decks on the runner, and the one whose absence
+ * shows up nowhere else: every test here runs on one machine, where both sides carry the same
+ * line ending and the difference hides.
+ */
+ @Test
+ void theSamePartWithEitherLineEndingIsOnePart() throws Exception {
+ String declaration = "";
+ byte[] unix = (declaration + "\n").getBytes(StandardCharsets.UTF_8);
+ byte[] windows = (declaration + "\r\n").getBytes(StandardCharsets.UTF_8);
+
+ assertThat(AssetContent.part(WATERMARK_DECK, "ppt/slides/slide1.xml", windows))
+ .describedAs("POI ends the XML declaration with the platform's line separator; "
+ + "that is the machine writing, not the document")
+ .isEqualTo(AssetContent.part(WATERMARK_DECK, "ppt/slides/slide1.xml", unix));
+ }
+
+ /** The stamp a package records for when it was written is not part of the document. */
+ @Test
+ void theCreationStampIsNotPartOfTheDocument() throws Exception {
+ String properties = "%s"
+ + "";
+ byte[] earlier = properties.formatted("2026-08-02T16:15:46Z")
+ .getBytes(StandardCharsets.UTF_8);
+ byte[] later = properties.formatted("2026-08-02T16:46:46Z")
+ .getBytes(StandardCharsets.UTF_8);
+
+ assertThat(AssetContent.part("word-export-companion.docx", "docProps/core.xml", earlier))
+ .describedAs("two renders of one document minutes apart are one document")
+ .isEqualTo(AssetContent.part("word-export-companion.docx", "docProps/core.xml",
+ later));
+ }
+
+ /**
+ * Every allowlisted part names a part that is really there.
+ *
+ * A misspelled document or part would match nothing, and an entry that matches nothing
+ * exempts nothing — the comparison would quietly go back to reading the watermark by its
+ * pixels and fail on a runner for the reason the entry was added to explain.
+ */
+ @Test
+ void everyAllowlistedPartExists() throws Exception {
+ for (String entry : new TreeSet<>(AssetContent.UNSTABLE_PARTS.keySet())) {
+ String[] split = entry.split("!", 2);
+ assertThat(split).describedAs("%s is not document!part", entry).hasSize(2);
+ for (Path document : List.of(COMMITTED.resolve(split[0]), generated(split[0]))) {
+ assertThat(document)
+ .describedAs("allowlisted document %s is missing", split[0]).exists();
+ assertThat(partNames(document))
+ .describedAs("allowlisted part %s is not in %s", split[1], document)
+ .contains(split[1]);
+ }
+ }
+ }
+
+ private static Path generated(String name) throws IOException {
+ try (var files = Files.walk(GeneratedCatalogue.ROOT)) {
+ return files.filter(path -> path.getFileName().toString().equals(name))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException(
+ "the catalogue no longer renders " + name));
+ }
+ }
+
+ private static Set partNames(Path archive) throws IOException {
+ Set names = new TreeSet<>();
+ try (ZipInputStream zip =
+ new ZipInputStream(new ByteArrayInputStream(Files.readAllBytes(archive)))) {
+ for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
+ names.add(entry.getName());
+ }
+ }
+ return names;
+ }
+
+ private static String part(Path archive, String name) throws IOException {
+ return new String(bytesOfPart(archive, name), StandardCharsets.UTF_8).replace("\r\n", "\n");
+ }
+
+ private static byte[] bytesOfPart(Path archive, String name) throws IOException {
+ try (ZipInputStream zip =
+ new ZipInputStream(new ByteArrayInputStream(Files.readAllBytes(archive)))) {
+ for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) {
+ if (entry.getName().equals(name)) {
+ return zip.readAllBytes();
+ }
+ }
+ }
+ throw new IllegalStateException(archive + " has no part " + name);
+ }
+
+ /** A blank image of a given size — a stand-in for whatever else could occupy that path. */
+ private static byte[] blankPng(int width, int height) throws IOException {
+ BufferedImage blank = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+ var bytes = new java.io.ByteArrayOutputStream();
+ ImageIO.write(blank, "png", bytes);
+ return bytes.toByteArray();
+ }
+
+ private static byte[] fixture(String name) throws IOException {
+ try (var in = AssetContentTest.class.getResourceAsStream("/pptx-media/" + name)) {
+ assertThat(in).describedAs("missing test fixture %s", name).isNotNull();
+ return in.readAllBytes();
+ }
+ }
+
+ private static BufferedImage decode(String name) throws IOException {
+ BufferedImage image = ImageIO.read(new ByteArrayInputStream(fixture(name)));
+ assertThat(image).describedAs("fixture %s does not decode as an image", name).isNotNull();
+ return image;
+ }
+
+ /** One line, turned a quarter turn, inside whatever box the caller gives it. */
+ private static String rotatedFreeform(String xfrm) {
+ return ""
+ + "" + xfrm + ""
+ + ""
+ + ""
+ + ""
+ + "";
+ }
+}