diff --git a/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/PrintAndPlayPageGeometryContractTests.cs b/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/PrintAndPlayPageGeometryContractTests.cs
new file mode 100644
index 00000000..873c3c2f
--- /dev/null
+++ b/Generation/Converters/Argumentum.AssetConverter.Tests/WebBasedGenerator/PrintAndPlayPageGeometryContractTests.cs
@@ -0,0 +1,231 @@
+using System;
+using Argumentum.AssetConverter;
+using FluentAssertions;
+using Xunit;
+
+namespace Argumentum.AssetConverter.Tests.WebBasedGenerator
+{
+ ///
+ /// Contract pin for — dispatch #204
+ /// primaire (cont. po-2024).
+ ///
+ /// The Print & Play PDF () lays cards out in a grid on each
+ /// sheet and must compute, from the page size, the card size, an optional header band, the page
+ /// margin and the deck length, FOUR derived quantities that drive the whole render:
+ ///
+ /// - NbColumns — the configured column count when > 0, ELSE the floor of
+ /// contentWidth / cardWidth (integer truncation, NOT rounding — a card that does not fully fit is dropped).
+ /// - NbRows — floor of contentHeight / cardHeight.
+ /// - NbCardsPerPage — NbRows × NbColumns.
+ /// - NbPages — ceil of frontImageCount / NbCardsPerPage (a partial last sheet still prints).
+ ///
+ /// A header band, when present, reserves pageHeight / 10 of the content height (matching the
+ /// header height drawn in ComposePage); the page margin (2 × pageMarginMm × factor) is
+ /// subtracted from BOTH width and height.
+ ///
+ /// This arithmetic was previously INLINED inside with
+ /// ZERO unit coverage — a regression in any of the four quantities (e.g. rounding instead of
+ /// truncating columns, forgetting the header reserve, or flooring instead of ceiling the page
+ /// count) silently changes how many sheets print and how cards distribute across them, caught only
+ /// by rendering and eyeballing the PDF. It has been extracted (output-neutral — the call site
+ /// preserves the exact computation) into the pure, deterministic
+ /// so the layout contract is unit-testable.
+ /// These tests pin the contract additively.
+ ///
+ public class PrintAndPlayPageGeometryContractTests
+ {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (1) COLUMNS — the configured value is honored when > 0; otherwise floor-divided.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Columns_PositiveConfig_IsHonoredVerbatim()
+ {
+ // FallaciesWeb A0 ships a fixed 12-column grid regardless of card width.
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 10_000f, pageHeightPoints: 10_000f,
+ cardWidthPoints: 1f, cardHeightPoints: 1f,
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: 12, frontImageCount: 1);
+
+ g.NbColumns.Should().Be(12);
+ }
+
+ [Fact]
+ public void Columns_ZeroConfig_FallsBackToFloorDivision()
+ {
+ // No configured columns → fit by width: contentWidth 600 / cardWidth 50 = 12 columns.
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 600f, pageHeightPoints: 800f,
+ cardWidthPoints: 50f, cardHeightPoints: 100f,
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: 0, frontImageCount: 1);
+
+ g.NbColumns.Should().Be(12);
+ }
+
+ [Fact]
+ public void Columns_NegativeConfig_AlsoFallsBack()
+ {
+ // The guard is `> 0`, so -1 (a sentinel / unset value) falls back like 0.
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 600f, pageHeightPoints: 800f,
+ cardWidthPoints: 50f, cardHeightPoints: 100f,
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: -1, frontImageCount: 1);
+
+ g.NbColumns.Should().Be(12);
+ }
+
+ [Fact]
+ public void Columns_FloorTruncation_NotRounding()
+ {
+ // 699 / 100 = 6.99 → truncates to 6, NOT 7. A card that does not fully fit in the
+ // remaining 99pts is dropped. This is THE fragile bit: Math.Round would yield 7.
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 699f, pageHeightPoints: 800f,
+ cardWidthPoints: 100f, cardHeightPoints: 100f,
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: 0, frontImageCount: 1);
+
+ g.NbColumns.Should().Be(6);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (2) ROWS — always floor-divided from the content height.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Rows_FloorDivisionOfContentHeight()
+ {
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 600f, pageHeightPoints: 850f,
+ cardWidthPoints: 50f, cardHeightPoints: 100f,
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: 0, frontImageCount: 1);
+
+ // 850 / 100 = 8.5 → 8 rows.
+ g.NbRows.Should().Be(8);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (3) CARDS PER PAGE = rows × columns.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void CardsPerPage_IsRowsTimesColumns()
+ {
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 600f, pageHeightPoints: 800f,
+ cardWidthPoints: 50f, cardHeightPoints: 100f,
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: 0, frontImageCount: 1);
+
+ // 12 cols × 8 rows = 96 cards/sheet.
+ g.NbCardsPerPage.Should().Be(96);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (4) PAGES — ceil(count / cardsPerPage). A partial last sheet still prints.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Pages_CeilsWhenRemainder()
+ {
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 600f, pageHeightPoints: 800f,
+ cardWidthPoints: 600f, cardHeightPoints: 800f, // 1 card/page
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: 1, frontImageCount: 5);
+
+ // 5 cards / 1 per page = 5 pages.
+ g.NbPages.Should().Be(5);
+ }
+
+ [Fact]
+ public void Pages_ExactMultiple_NoExtraSheet()
+ {
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 600f, pageHeightPoints: 800f,
+ cardWidthPoints: 600f, cardHeightPoints: 800f, // 1 card/page
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: 1, frontImageCount: 3);
+
+ g.NbPages.Should().Be(3);
+ }
+
+ [Fact]
+ public void Pages_PartialLastSheet_StillPrints()
+ {
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 600f, pageHeightPoints: 800f,
+ cardWidthPoints: 50f, cardHeightPoints: 100f, // 96 cards/page
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: 12, frontImageCount: 97);
+
+ // 97 / 96 = 1.0104 → ceil → 2 pages (last sheet holds the 1 leftover card).
+ g.NbPages.Should().Be(2);
+ }
+
+ [Fact]
+ public void Pages_ZeroCards_IsZero()
+ {
+ // An empty deck produces zero pages (ceil(0 / n) == 0). Important: this does NOT hit the
+ // degenerate divide-by-zero, because cardsPerPage is non-zero here.
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 600f, pageHeightPoints: 800f,
+ cardWidthPoints: 50f, cardHeightPoints: 100f,
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: 12, frontImageCount: 0);
+
+ g.NbPages.Should().Be(0);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (5) HEADER — reserves 1/10 of page height (matches ComposePage's header band).
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Header_ReservesOneTenthOfPageHeight()
+ {
+ var withHeader = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 600f, pageHeightPoints: 1000f,
+ cardWidthPoints: 50f, cardHeightPoints: 100f,
+ totalMarginPoints: 0f, hasHeader: true,
+ configuredNbColumns: 12, frontImageCount: 1);
+
+ // pageHeight 1000 − header 100 = content 900 → 9 rows (vs 10 without header).
+ withHeader.NbRows.Should().Be(9);
+ }
+
+ [Fact]
+ public void NoHeader_KeepsFullContentHeight()
+ {
+ var noHeader = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 600f, pageHeightPoints: 1000f,
+ cardWidthPoints: 50f, cardHeightPoints: 100f,
+ totalMarginPoints: 0f, hasHeader: false,
+ configuredNbColumns: 12, frontImageCount: 1);
+
+ noHeader.NbRows.Should().Be(10);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // (6) MARGIN — subtracted from BOTH width and height.
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Margin_SubtractedFromBothDimensions()
+ {
+ var g = PrintAndPlayDocument.ComputePageGeometry(
+ pageWidthPoints: 610f, pageHeightPoints: 810f,
+ cardWidthPoints: 50f, cardHeightPoints: 100f,
+ totalMarginPoints: 10f, hasHeader: false, // 10pt margin each side
+ configuredNbColumns: 0, frontImageCount: 1);
+
+ // width: 610 − 10 = 600 → 12 cols ; height: 810 − 10 = 800 → 8 rows.
+ g.NbColumns.Should().Be(12);
+ g.NbRows.Should().Be(8);
+ }
+ }
+}
diff --git a/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/PrintAndPlayDocument.cs b/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/PrintAndPlayDocument.cs
index e6820eaf..cc8b5c49 100644
--- a/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/PrintAndPlayDocument.cs
+++ b/Generation/Converters/Argumentum.AssetConverter/WebBasedGenerator/PrintAndPlayDocument.cs
@@ -48,17 +48,15 @@ public void Compose(IDocumentContainer container)
var cardHeightPoints = ((float)_docConfig.CardSets[0].FrontCards.HeigthMM) * MmToPointsFactor;
var totalMarginPoints = 2 * pageMarginMm * MmToPointsFactor;
- var contentWidthPoints = pageSize.Width - totalMarginPoints;
+ var hasHeader = !string.IsNullOrEmpty(_docConfig.Header);
- // ✅ FIX: Soustraire la hauteur du header de l'espace disponible pour le contenu
- // Le header utilise pageSize.Height / 10, donc on le soustrait de contentHeightPoints
- var headerHeightPoints = !string.IsNullOrEmpty(_docConfig.Header) ? pageSize.Height / 10 : 0;
- var contentHeightPoints = pageSize.Height - totalMarginPoints - headerHeightPoints;
-
- int nbColumns = _docConfig.NbColumns > 0 ? _docConfig.NbColumns : (int)(contentWidthPoints / cardWidthPoints);
- var nbRows = (int)(contentHeightPoints / cardHeightPoints);
- var nbCardsPerPage = nbRows * nbColumns;
- var nbPages = (int)Math.Ceiling((decimal)_frontImagesData.Count / (decimal)nbCardsPerPage);
+ // ✅ Page-grid geometry (nbColumns/nbRows/nbCardsPerPage/nbPages) extracted output-neutral
+ // into ComputePageGeometry so this fragile layout contract is unit-testable without a render.
+ var geometry = ComputePageGeometry(pageSize.Width, pageSize.Height, cardWidthPoints, cardHeightPoints,
+ totalMarginPoints, hasHeader, _docConfig.NbColumns, _frontImagesData.Count);
+ int nbColumns = geometry.NbColumns;
+ var nbCardsPerPage = geometry.NbCardsPerPage;
+ var nbPages = geometry.NbPages;
for (int pageIndex = 0; pageIndex < nbPages; pageIndex++)
{
@@ -104,6 +102,69 @@ public static T[] ReorderBacksForRectoVerso(IList backs, int nbColumns)
.ToArray()
.Flatten();
+ ///
+ /// Page-grid geometry for a Print & Play sheet: how many card columns/rows fit, how many
+ /// cards per page, and how many pages the deck spans.
+ ///
+ public readonly struct PrintPlayPageGeometry
+ {
+ /// Grid columns per sheet. The configured value when > 0, else floor(contentWidth / cardWidth).
+ public readonly int NbColumns;
+ /// Grid rows per sheet: floor(contentHeight / cardHeight).
+ public readonly int NbRows;
+ /// Cards that fit on one sheet: NbRows × NbColumns.
+ public readonly int NbCardsPerPage;
+ /// Sheets needed for the whole deck: ceil(frontImageCount / NbCardsPerPage).
+ public readonly int NbPages;
+
+ public PrintPlayPageGeometry(int nbColumns, int nbRows, int nbCardsPerPage, int nbPages)
+ {
+ NbColumns = nbColumns;
+ NbRows = nbRows;
+ NbCardsPerPage = nbCardsPerPage;
+ NbPages = nbPages;
+ }
+ }
+
+ ///
+ /// Derives the page-grid geometry from the resolved page/card sizes. Pure & deterministic —
+ /// no QuestPDF dependency, no I/O. Reproduces the exact arithmetic previously inlined in
+ /// (output-neutral):
+ ///
+ /// - contentWidth = pageWidth − totalMargin
+ /// - header reserves pageHeight / 10 when (matches 's header height)
+ /// - contentHeight = pageHeight − totalMargin − headerHeight
+ /// - NbColumns = configuredColumns when > 0, else floor(contentWidth / cardWidth) — TRUNCATION, not rounding
+ /// - NbRows = floor(contentHeight / cardHeight)
+ /// - NbCardsPerPage = NbRows × NbColumns
+ /// - NbPages = ceil(frontImageCount / NbCardsPerPage)
+ ///
+ /// Degenerate inputs where NbCardsPerPage == 0 (card larger than the content area, or zero
+ /// columns) make NbPages divide by zero — preserved as-is (NOT guarded) to stay output-neutral;
+ /// flagged for a separate behavior-change PR.
+ ///
+ public static PrintPlayPageGeometry ComputePageGeometry(
+ float pageWidthPoints, float pageHeightPoints,
+ float cardWidthPoints, float cardHeightPoints,
+ float totalMarginPoints,
+ bool hasHeader,
+ int configuredNbColumns,
+ int frontImageCount)
+ {
+ var contentWidthPoints = pageWidthPoints - totalMarginPoints;
+ var headerHeightPoints = hasHeader ? pageHeightPoints / 10 : 0;
+ var contentHeightPoints = pageHeightPoints - totalMarginPoints - headerHeightPoints;
+
+ int nbColumns = configuredNbColumns > 0
+ ? configuredNbColumns
+ : (int)(contentWidthPoints / cardWidthPoints);
+ int nbRows = (int)(contentHeightPoints / cardHeightPoints);
+ int nbCardsPerPage = nbRows * nbColumns;
+ int nbPages = (int)Math.Ceiling((decimal)frontImageCount / (decimal)nbCardsPerPage);
+
+ return new PrintPlayPageGeometry(nbColumns, nbRows, nbCardsPerPage, nbPages);
+ }
+
private void ComposePage(PageDescriptor page, PageSize pageSize, float pageMarginMm, int nbColumns, IEnumerable images)
{
page.Size(pageSize);