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 + "" + + "" + + "" + + "" + + ""; + } +}