From 86dad191ef72da6a0ddf1665822a05ea593eb184 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 2 Aug 2026 11:53:01 +0100 Subject: [PATCH 1/6] fix(examples): render the weights the styles declare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one of the 115 font styles in this module named a face constant — HELVETICA_BOLD, TIMES_BOLD, COURIER_BOLD — and set no decoration. The library rewrites such a name to its base family before the lookup, so the constant contributed nothing; the same defect the donut KPI had, one module over and a hundred times. It was invisible until the PPTX backend started taking the face from the decoration in the 2.1 line. From that point a fresh render of the decks came out regular while the committed previews, rendered earlier, still showed bold — and nothing regenerated them, so nobody saw it. Three decks lost weight that way; a fourth gained twenty-one runs it had always asked for and never received. The styles now name a family and a decoration. Where a chain already set one, only the constant is renamed: it was redundant, not load-bearing. Three helpers chose the constant through a ternary and needed the flag moved to the decoration instead. Two consequences worth naming. Bold text is wider, so four layout snapshots move — widths and vertical positions only, no page breaks. And the weekly schedule stopped fitting: its day notes span four fixed sub-columns, a spanned cell cannot borrow width from fixed neighbours, and the board is printed. Its columns are recomputed from the printable width rather than adjusted by eye — 87.38 + 7 x 105.5 against 825.89 available, where the old 90 + 7 x 105 left 0.89pt unused. Thirty-one committed previews are re-rendered, chosen by comparing content rather than bytes so nothing churns on a fresh /ID alone. PptxCanonicalContentTest keeps that comparison: a deck is compared by its package parts, since zip timestamps make byte equality a coin toss. --- CHANGELOG.md | 10 + assets/readme/examples/barcode-showcase.pdf | Bin 11443 -> 11425 bytes assets/readme/examples/business-report.pdf | Bin 5201 -> 5255 bytes assets/readme/examples/business-report.pptx | Bin 43366 -> 43360 bytes .../readme/examples/canvas-layer-showcase.pdf | Bin 1860 -> 1846 bytes .../examples/composed-table-cell-showcase.pdf | Bin 3232 -> 3149 bytes assets/readme/examples/cover-letter.pdf | Bin 3387 -> 3395 bytes assets/readme/examples/emoji-shortcodes.pdf | Bin 44610 -> 44617 bytes assets/readme/examples/engine-deck-v2.pdf | Bin 35714 -> 35750 bytes assets/readme/examples/engine-deck.pdf | Bin 580376 -> 580484 bytes assets/readme/examples/feature-catalog.pdf | Bin 215333 -> 215475 bytes assets/readme/examples/financial-report.pdf | Bin 188315 -> 188344 bytes assets/readme/examples/financial-report.pptx | Bin 411352 -> 411341 bytes .../examples/inline-code-column-wrap.pdf | Bin 3752 -> 3529 bytes .../examples/inline-highlight-chips.pdf | Bin 3804 -> 3773 bytes assets/readme/examples/inline-shapes.pdf | Bin 4584 -> 4502 bytes assets/readme/examples/inline-svg-icons.pdf | Bin 3938 -> 3944 bytes assets/readme/examples/invoice-cinematic.pdf | Bin 3145 -> 3129 bytes .../readme/examples/invoice-http-stream.pdf | Bin 3145 -> 3129 bytes .../examples/invoice-snapshot-regression.pdf | Bin 3145 -> 3129 bytes assets/readme/examples/master-showcase.pdf | Bin 12987 -> 12993 bytes assets/readme/examples/master-showcase.pptx | Bin 49544 -> 49578 bytes assets/readme/examples/maven-banner.pdf | Bin 10078 -> 10095 bytes assets/readme/examples/maven-banner.pptx | Bin 31246 -> 31234 bytes .../readme/examples/nested-list-showcase.pdf | Bin 2364 -> 2345 bytes assets/readme/examples/pdf-chrome.pdf | Bin 4210 -> 4210 bytes assets/readme/examples/rich-text-showcase.pdf | Bin 3878 -> 3931 bytes assets/readme/examples/section-presets.pdf | Bin 3064 -> 3058 bytes assets/readme/examples/twin-output.pdf | Bin 13835 -> 13768 bytes assets/readme/examples/twin-output.pptx | Bin 56698 -> 56696 bytes assets/readme/examples/weekly-schedule.pdf | Bin 8034 -> 8344 bytes .../readme/examples/word-export-companion.pdf | Bin 12905 -> 12915 bytes assets/readme/repository_showcase_render.png | Bin 389947 -> 391342 bytes .../barcodes/BarcodeShowcaseExample.java | 4 +- .../features/canvas/CanvasLayerExample.java | 14 +- .../features/chrome/PdfChromeExample.java | 4 +- .../features/docx/WordExportExample.java | 10 +- .../features/lists/NestedListExample.java | 7 +- .../shapes/ShapeContainerExample.java | 14 +- .../features/svg/SvgIconGalleryExample.java | 2 +- .../tables/ComposedTableCellExample.java | 9 +- .../tables/InlineCodeColumnWrapExample.java | 7 +- .../features/tables/TableAdvancedExample.java | 4 +- .../text/EmojiClipPathReportExample.java | 4 +- .../features/text/EmojiGalleryExample.java | 3 +- .../features/text/EmojiShortcodeExample.java | 4 +- .../features/text/EmojiSvgVsPngExample.java | 5 +- .../features/text/InlineHighlightExample.java | 7 +- .../features/text/InlineShapesExample.java | 4 +- .../features/text/InlineSvgIconExample.java | 4 +- .../text/RichTextShowcaseExample.java | 10 +- .../features/text/SectionPresetsExample.java | 7 +- .../transforms/TransformsExample.java | 12 +- .../flagships/BusinessReportExample.java | 37 ++- .../examples/flagships/EngineDeckCharts.java | 4 +- .../examples/flagships/EngineDeckExample.java | 34 ++- .../flagships/EngineDeckV2Example.java | 10 +- .../examples/flagships/EngineShowcase.java | 19 +- .../flagships/FeatureCatalogExample.java | 16 +- .../flagships/FinancialReportExample.java | 22 +- .../flagships/LinkedInCarouselExample.java | 13 +- .../flagships/MasterShowcaseExample.java | 16 +- .../flagships/MavenBannerPptxExample.java | 5 +- .../examples/flagships/TwinOutputExample.java | 6 +- .../support/WeeklyScheduleRenderer.java | 42 ++- .../examples/support/theme/BusinessTheme.java | 10 +- .../coverletter/CoverLetterFileExample.java | 22 +- .../CinematicProposalFileExample.java | 2 +- .../examples/PptxCanonicalContentTest.java | 123 +++++++++ .../flagships/engine-deck-v2.json | 56 ++-- .../flagships/engine-deck.json | 260 +++++++++--------- .../flagships/financial-report.json | 8 +- .../flagships/twin-output.json | 8 +- 73 files changed, 551 insertions(+), 307 deletions(-) create mode 100644 examples/src/test/java/com/demcha/examples/PptxCanonicalContentTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 2405fb61c..e6e39baec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,16 @@ follow semantic versioning; release dates are ISO 8601. ### Fixed +- **The example catalogue renders the weights it declares.** The same defect the donut + KPI had, across 115 styles in the examples: a font *face* constant named with no + decoration, which the library rewrites to its base family before the lookup. The PPTX + backend began reading the face from the decoration in the 2.1 line, so decks that had + rendered bold went out regular — a live loss on `develop`, invisible because nothing + re-rendered the committed previews to show it. Restoring the weights changed three + decks back and gave a fourth the emphasis it had always asked for. Thirty-one committed + previews are re-rendered; the weekly schedule's columns are recomputed to fill the + printable width, because bold day notes no longer fit a span over fixed sub-columns and + a spanned cell cannot borrow width from them. - **The donut-centre KPI renders the weight it declares.** A style names a font *family* and a *decoration*, and the decoration is what picks the face within the family — the standard-14 face constants (`HELVETICA_BOLD`, `TIMES_ITALIC`, …) are diff --git a/assets/readme/examples/barcode-showcase.pdf b/assets/readme/examples/barcode-showcase.pdf index 42e87e5cacefc64e68c0c81b68dcc3cd07585f89..8154e8371e187469585f86dbd2ef7ad46dd7e29c 100644 GIT binary patch delta 2421 zcma)*c{J3G8pq#}u?)kgnTfK@6s57vhS4jGhKz|Ay0X0*TSTU;l^D&~23PWnDBdiI z;vF$WM#R2ebMHOpcJ8_7-2a|)zTfkCo^!t6hwq&4oMsC$xM>F$ zGav#xvPb}?4F$H*GOX94An&foLx!GWt*U2%TGAzjB=)oU6OUEsn;Q#C)V^bRc7uvR zRE);zZTkj~_j;|%r#Hd|t8O#9&+zpg_5OSq&M8K?Box0JsfcoLEb|5=5=)E4t-G3fs}*SnUcMQ zVf1`y?w);ju9O@>PE2+-@U=ddgI6UQB-CU+VOn{ zG+MIkbgoom?LLLZCg$1iSJ$e&@Ql&I(U_R&4@=uZ*|DInUWTQD@Q^$^Y%!qkcO|^W z&Ampj-M~2n^tt^qsvX=tJX+UHI+eMRQbss0-_9GlWW##dge)_;Cb(c~Yx4ZNt$rv? zD0t{p+SvLmvgmq2b-(1Q?cBz?E7dDapd^YJx8tw}tyicOy0L}J5Wih*A7eF2KBLMW zS-U@Nr$D|MYh2FDkXdL9Q$7Ga&BS!2sd!&ysV7n}=VR3!6LqTJXR^~?alJOn`Z~-C z@D=rTeXJH~o3^q~bMqT>!Ra&^G=wWWUKT5*7>X+Tf-G=j?>)~C4r8=*FT8`IM9zp2G z_=9m9R41AIR3T}J4e4_#jg9QMc%v?tObdbDIxSGC&E@~F1>dL+lC|5#u{>S?^2Ys;Dr17c*)}so>3k%4MeO{4c5#C_2Lj@W{|JC;Rk(2+Y{ z?_UnnUh*+FAE+9FggAm}rq55lx;PN48tgs29ksSyzBIT=;qFwkbbIbMQ_URY&q)-J zT(M>FW+H{SyoS!!96Ah3Qi!&~I!fUl85e{S(Mg3G+^< zSs`OBilOAJlp2Ja(Hqabm_6!rZGho9T+O+JVG_@WGSc3?jqdB;ugj{_6Q9{+^9rL{ z!Dqm%A(J4R7?x3=WbOVCt3F(UpQ0PAzBg=j!ite3zVH5R31ly$^U@*>t)iK-NgCM7 zM(uf6vOC?o8!go*<1BHYEtwq~=J)G1JeP%B&=Xz@K?%e-pW#U(0gQfaa1+9}wNjxqKqy zmELvd2fK+H+Npx61e~erQ&nThsR(gzZyc#EKWAaCD=e~0S66KvPZ-fX%BXJ_BrA=| z{ds{zgY}>{>+#(M-d6qa*acqky}1wLi|;wEoGp!Ge$PjgUudj$G_zHZ3w1tS;nmNE z7wi{ViZ9^tP>4^EcSvZk597jL6VvlNgv1veb_W5EWe_*L66EnX6c$B?=sDOhLIP0u ziZu}!XqSZpo09QB5Aq@JfH7iCjDQ7hEA0TihvEM;;*KEyBw8Ig&T??4HJiaUwU#9Rh&sGi6YQ#u zRG=Z?N%H?2971 zyZSn;hi-1==Yxi)sK;Bja1X$op$}p6iQ;FU#-%X4#H8<+smeo_M?h(#k_d%s zOeHU+`tdl-_Z{vXxOn7F6O~-+r?v+>_InkPCAi^V+Gg_L*%w>dN>^?;F)y3qA>cz)xW9+ z$Z2V?@DM#ZlNpNoSGZIP%GH=evA~gW<`f(bi#4|(Q;diN0^Y!!fF~FoH#agOA2-C~ z@MN;Ng$0RXNzlhrh(`J(0tJi1S`dj?yoCws|3)bOF9y)Abr7TvENWp{?KX0aKPkpe zUMXEq$c&3?d2pKC0=+81?Qk~Y{MxLq-1L&oM4lQuiBFF%u`lSCqGsilMp%c36r*LX zXqqcwzS@iSR;(wkxCcs9M140cjI0X@*NY(Uga>@{sF}2@rKJ{;R^GXNls|DD3jgmP T1ah?Hq;VvOhK9u{OUOR}{^e2@ delta 2432 zcmbu7dpy&N8^_x)GuI4ri_NxJ$mX^Ur?Q#Ga7fN@$R)Xptg|FcNMjD_n;fBXt6b7D z$F1CwTP2q+3njS}M{c?0636fS@q7Kw-@m_}Kc4sN^Socr^AxxV+%g@&1U&Fi{DvHX zfW#w@!3>=3eOMt#f+0{K4;P$9x|CcZ%2rQL0!QkEc z11kvvDzy{cf&(Lr{U3+%dYIhd!Q2nx;q3OQ@i2s=Xy6?Cci1=S~ zU=yn#N-!px0Qmh%KrBR>GwaG=y`sxC%`ji=4JLkjtXaPouCuy_F*f#;rUP58)NEzb z7w2mOF!r@vu$$Ux6s`M%#>%7Sxy5YXZ&m9J_i}l{fQk{t$5JDVp3&)I!eZ&Ag-|9U z?~H!}uEgjU!UNiLY(#ok?pQ;VS4_P@w`+KjsMyZvwB@z5tJVITn~&yJTzsnaN@QE% zbMkcyZgc~vP}!o?Hq&3?`K4`FPr1rvtaxRHmIU*9$Izbn027C0?SAA0w)~2R(zEO# zvJccbNQ<=hI~yYseb=}l6I)P}2z}G8u4TcUUzQ)6K|Ur_5;_c{DhDU4`@U8xrZ2is z+YC@{1kV7$lL!tZmGMPdJN+-LNyg4qUp+I(Q#be+*wZ9M!oJ@v)fmhs8X}h{CyMb z^(DPY?~LlB7PULW$@1S=H!dnrz1tFDt8V zb)hG(5;l!%6deK|kD7)uA$`5)gX+GRI>no*oYuM@D;o0AmTv6LpG+ZiaJeks#}ZUd2&4h)~l5Ma)p8ja<#Kc38IQpj|_B-CDDXnY@6?CFKn zkI1;}pEiPfE3!+Vh2l~7oCuJGLbU;k_`z`_*P9*Nf;B`a%$rv3`bW*v@mFoG*9)a6 zRJGUTzTf<@@19FP-19X#N4YIaFa854ay3x}&T-Da86=XjKW5NcGIDtAENI&7-c>bO zc3Y}8%D&m%pMOWoIgdoul;(t*`x{T>q1 zX_dlB$}MAbHkJx~D`p%Df9fi#K_g&thB`F-jR?hen~fUJjC71x{TtX%t0xMT(%N11 zWGURc#ar9Oe(#+4r`b<3Xt&;dms({FojX>uaFXm?MQl$+L2ba4zzhiq-s@IJg*8)r zfGCeEzr#RIuPjgW49m`s*!BL5$zDyGfpcEAkJm_qxe)D#g1E-{$zAqKJ)%>p2yu_X z2o0J$&NFysV78re3tDY{qeaRk=FeatK2Y*dvnLILF z*zOA(!^J5dZmu;_aU@ko#?9ep-fneiqf$1(oKOHxqAU*3ux*&GSQ z@a=fiQTE(N#Qe6|t(OkfTV69@eX~5#>g5Q3BqBiaPS!WR)D^WeO$Pg)&BCB>y7~~x zs!0~X7Ln6|l!NKlE0U||_5O3WgC&TF@Lv-9m3g19NVYk<$9cJ1|B_sU`n=UX3@?DJ zW%X&X0+$tapy>`w8f$zUl6y`B|PX0ux+)qIup=myX;Z+3zU7Q zdgSyh& zFYgp%wZ`9R>P5>A6>;eZps3fsuHYZC$jL zDcNap4dr}Y;x=Y)^_Y;ZuF0Db-oNZRk%oz*$X_?>OQ|;O+gDsUaWI=L> zIG4}BLV`K@D%a~O7HtbJ|CFXm&aUs`cZGa(va)`-Z2#_!n6`1Fk80WVAJSdFB_HZf zyZ=7^;MnT7Pu&Rps@UeSj$_u)aUNHoPl_>KL7Aa&4dANN#8* z`~=_f^X?0|lW@cSpJsyIg;EtSPetXK^fm@kd(2g5+~-YQ+MZ+0v9I+wYGE&S3jW+w zyb*V4yEue%-q$^@j$p4Dp0)!KXJ*O#rAGK2eAMBe%Sh@ff(QUg7tXOX!IMqNG@>Ed zgkne~Q4Nhqrc@f9XhNkB$)+R{2~V~rS(2?xXapk~i9oa@QAk7zfj~7eCR>|Y8y!Mg z{67(*|H%MqbH^z~kjoNwf$aT;4{iD;4r8}d{Z?Lfc@=(-nlGI%%xPo|@up6Xi2cw? rmz}@*%2_IWKC#rUXI;CpXxmg(I%RhgaDb;LEn!52VX?HItzmxyf&f&y diff --git a/assets/readme/examples/business-report.pdf b/assets/readme/examples/business-report.pdf index e3a7c446018ee08374ac9137f7d24e637edb25ae..1caf46035c10a81b83bde5fb118b30e7194b0850 100644 GIT binary patch delta 4625 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 */ - private static byte[] canonicalise(String name, byte[] content) throws IOException { + private static byte[] canonicalise(String deck, String name, byte[] content) + throws IOException { if (name.startsWith("ppt/media/")) { - return imageShape(content); + return image(content, UNSTABLE_RASTERS.contains(deck + "!" + name)); } if (!name.endsWith(".xml") && !name.endsWith(".rels")) { return content; @@ -258,14 +367,41 @@ private static byte[] canonicalise(String name, byte[] content) throws IOExcepti return freeformsInSlideSpace(text).getBytes(StandardCharsets.UTF_8); } - /** An image's dimensions and pixel layout — a vector part is left as it is. */ - private static byte[] imageShape(byte[] content) throws IOException { + /** + * 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("master-showcase.pptx!ppt/media/image1.png"); + + /** + * 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; } - return "%dx%d type=%d".formatted(image.getWidth(), image.getHeight(), image.getType()) - .getBytes(StandardCharsets.UTF_8); + 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); diff --git a/examples/src/test/resources/pptx-media/watermark-linux.png b/examples/src/test/resources/pptx-media/watermark-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..1a1ef5bcc571f4de7b3661edfe97ed73f60e490d GIT binary patch literal 6988 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�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 amB=d7ETccVj<2{obi>Bcy6LLl@BahQXh*F8 literal 0 HcmV?d00001 From e74d507a79e77447ecbbe81f652e1989fae59376 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 2 Aug 2026 16:47:26 +0100 Subject: [PATCH 6/6] test(examples): route the image checks through the dispatcher, and count what changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two image checks called the pixel reader directly, so a wrong media prefix or a mistyped allowlist key was covered by neither: the part would have fallen through to raw bytes and failed on a runner instead. Both now go through the same entry point the digest uses, and the negative one names a deck the allowlist does not, so the key has to be read as deck and part rather than part alone. The allowlisted part is checked in the rendered deck as well as the committed one — the rendered one is what a gate compares. Line endings were the one canonicalisation nothing pinned. It is also the one that cannot fail here, since every test runs on a single machine where both sides carry the same ending, so it is asserted directly. The CHANGELOG said 115 styles. That is how many named a face constant in a plain fontName call; another 23 reached one through a ternary or a helper argument, and all 138 changed. The class summary said every image is read pixel for pixel, one paragraph above the exception it names. --- CHANGELOG.md | 4 +- .../examples/PptxCanonicalContentTest.java | 78 ++++++++++++++----- 2 files changed, 61 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d2a9d76..347361daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,13 +101,13 @@ follow semantic versioning; release dates are ISO 8601. ### Fixed - **The example catalogue renders the weights it declares.** The same defect the donut - KPI had, across 115 styles in the examples: a font *face* constant named with no + KPI had, 138 times over in the examples: a font *face* constant named with no decoration, which the library rewrites to its base family before the lookup. The PPTX backend began reading the face from the decoration in the 2.1 line, so decks that had rendered bold went out regular — a live loss on `develop`, invisible because nothing re-rendered the committed previews to show it. Restoring the weights changed four decks back and gave a fifth the emphasis it had always asked for. Thirty-one committed - previews are re-rendered; the weekly schedule's columns are recomputed to fill the + previews and the README hero are re-rendered; the weekly schedule's columns are recomputed to fill the printable width, because bold day notes no longer fit a span over fixed sub-columns and a spanned cell cannot borrow width from them. - **The donut-centre KPI renders the weight it declares.** A style names a font diff --git a/examples/src/test/java/com/demcha/examples/PptxCanonicalContentTest.java b/examples/src/test/java/com/demcha/examples/PptxCanonicalContentTest.java index ba03e3e3d..15defbe55 100644 --- a/examples/src/test/java/com/demcha/examples/PptxCanonicalContentTest.java +++ b/examples/src/test/java/com/demcha/examples/PptxCanonicalContentTest.java @@ -14,6 +14,7 @@ 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; @@ -42,7 +43,8 @@ * 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.

+ * 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 @@ -62,9 +64,9 @@ static void generateEveryExample() throws Exception { } /** - * Every deck the catalogue produces, split by whether its preview is published. + * The decks whose preview the repository commits. * - *

Which decks the repository commits a preview of is a decision, and both lists + *

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 @@ -213,9 +215,11 @@ void sameDimensionsButDifferentPixelsAreNotEqual() throws Exception { assertThat(windows.getWidth()).isEqualTo(linux.getWidth()); assertThat(windows.getHeight()).isEqualTo(linux.getHeight()); - assertThat(image(fixture(WATERMARK_WINDOWS), false)) - .describedAs("two images of one size are not one image") - .isNotEqualTo(image(fixture(WATERMARK_LINUX), false)); + 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))); } /** @@ -235,10 +239,30 @@ void theNamedUnstableRasterAbsorbsTheMeasuredDifference() throws Exception { .describedAs("the fixtures have to be two renders, not one file twice") .isNotEqualTo(linux); - assertThat(image(windows, true)) + 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(image(linux, true)); + .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)); } /** @@ -253,11 +277,21 @@ 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); - Path deck = COMMITTED.resolve(split[0]); - assertThat(deck).describedAs("allowlisted deck %s is not committed", split[0]).exists(); - assertThat(partNames(deck)) - .describedAs("allowlisted part %s is not in %s", split[1], split[0]) - .contains(split[1]); + 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)); } } @@ -274,6 +308,8 @@ private static Set partNames(Path pptx) throws IOException { 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)) { @@ -283,7 +319,9 @@ private static byte[] fixture(String name) throws IOException { } private static BufferedImage decode(String name) throws IOException { - return ImageIO.read(new ByteArrayInputStream(fixture(name))); + 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 { @@ -376,7 +414,7 @@ private static byte[] canonicalise(String deck, String name, byte[] content) * put this entry here.

*/ private static final Set UNSTABLE_RASTERS = - Set.of("master-showcase.pptx!ppt/media/image1.png"); + Set.of(WATERMARK_DECK + "!" + WATERMARK_PART); /** * An image's pixels in one colour model, or — for a named unstable part — its size. @@ -410,6 +448,8 @@ private static byte[] image(byte[] content, boolean sizeOnly) throws IOException 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) { @@ -429,8 +469,8 @@ private static String inSlideSpace(String shape) { if (!offset.find() || !extent.find() || !pathTag.find()) { return shape; } - long pathWidth = attribute(pathTag.group(1), 'w'); - long pathHeight = attribute(pathTag.group(1), 'h'); + long pathWidth = attribute(PATH_WIDTH, pathTag.group(1)); + long pathHeight = attribute(PATH_HEIGHT, pathTag.group(1)); if (pathWidth <= 0 || pathHeight <= 0) { return shape; } @@ -466,8 +506,8 @@ private static String withoutPathBox(String shape) { return out.toString(); } - private static long attribute(String attributes, char name) { - Matcher value = Pattern.compile(name + "=\"(\\d+)\"").matcher(attributes); + private static long attribute(Pattern attribute, String attributes) { + Matcher value = attribute.matcher(attributes); return value.find() ? Long.parseLong(value.group(1)) : -1; } }