From 0f384cb3d7a76651981477197709079f31ed0f42 Mon Sep 17 00:00:00 2001 From: tpskowski <98412204+tpskowski@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:02:59 -0400 Subject: [PATCH 1/2] Print cards at true size and edge to edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards are drawn at 63 x 88mm — the finished size of a Magic: the Gathering card — but the sheet always declared @page size: A4. Printing that on US Letter makes the browser scale the whole sheet by 279.4/297, so the cards come out 59 x 83mm. Pick the paper in the print toolbar instead (guessed from the browser locale, then remembered) and emit the matching @page rule, so the sheet is never scaled. Cards now abut with no gutter and square corners, so one straight cut separates two of them instead of leaving a strip of waste. That also shrinks the block to 189 x 264mm, which is what lets three 88mm rows fit Letter's 279.4mm sheet at all — the old 2mm row gaps needed 268mm. The toolbar states the card size and that the sheet must print at 100% scale, since "fit to page" shrinks it just as the wrong paper does. Layout tests now assert the 189 x 264mm gutterless grid, and that each paper size produces its own PDF MediaBox with no extra sheets. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 33 +++++++++++ src/components/PrintView.tsx | 43 ++++++++++++++ src/styles.css | 39 +++++++++---- tests/cards.spec.ts | 107 +++++++++++++++++++++++++++++++++-- 4 files changed, 206 insertions(+), 16 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5c48662..6f05e72 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,10 @@ import { HamburgerIcon, MoonIcon, SunIcon } from "./components/Icons"; import MagicItemsDialog from "./components/MagicItemsDialog"; import PrintView, { defaultCardPrintOptions, + guessPaperSize, + PAPER_SIZES, type CardPrintOptions, + type PaperSize, type PrintMode, } from "./components/PrintView"; import Roster from "./components/Roster"; @@ -158,6 +161,18 @@ export default function App() { localStorage.setItem("warmuster.duplexOffset", String(duplexOffset)); }, [duplexOffset]); + // Paper in the printer. Cards are drawn at their true 63 x 88mm size, so + // this has to match the tray or the browser scales the sheet to fit and the + // cards print under size. Guessed from the browser locale until picked. + const [paperSize, setPaperSize] = useState(() => { + const stored = localStorage.getItem("warmuster.paperSize"); + return stored != null && stored in PAPER_SIZES ? (stored as PaperSize) : guessPaperSize(); + }); + + useEffect(() => { + localStorage.setItem("warmuster.paperSize", paperSize); + }, [paperSize]); + // The print preview lives on its own history entry, so the browser Back // button closes it (returning to the app) instead of leaving the site. const openPrint = (mode: PrintMode) => { @@ -455,6 +470,20 @@ export default function App() { + {printMode === "cards" && (
+ + Cards are 63 × 88 mm; print at 100% scale (not “fit to page”). +
)} @@ -506,6 +538,7 @@ export default function App() { list={activeList} army={army} duplexOffsetMm={printMode === "cards" ? duplexOffset : 0} + paperSize={paperSize} cardOptions={cardPrintOptions} scoutingEnabled={scoutingEnabled} /> diff --git a/src/components/PrintView.tsx b/src/components/PrintView.tsx index 1a2cb04..40d722a 100644 --- a/src/components/PrintView.tsx +++ b/src/components/PrintView.tsx @@ -31,6 +31,44 @@ export const defaultCardPrintOptions: CardPrintOptions = { includeMagicItemsOnUnits: true, }; +/* Paper the sheets are laid out for. Cards are drawn at their true finished + * size (63 x 88mm, the same as a Magic: the Gathering card) and must print at + * 100% scale to come out that size — so the @page size has to match the paper + * actually in the tray. Declaring A4 and printing on US Letter makes the + * browser shrink the whole sheet to 279.4/297 = 94%, and the cards come out + * 59 x 83mm. + * + * Three 88mm rows are 264mm tall, which leaves Letter (279.4mm) just 15.4mm + * for both margins — hence the tighter 6mm here. A printer whose unprintable + * border is wider than that cannot fit 3 x 3 cards on Letter at full size. */ +export const PAPER_SIZES = { + a4: { label: "A4", size: "A4 portrait", margin: "8mm 6mm" }, + letter: { label: "US Letter", size: "Letter portrait", margin: "6mm" }, +} as const; + +export type PaperSize = keyof typeof PAPER_SIZES; + +export const defaultPaperSize: PaperSize = "a4"; + +/** Regions that use US Letter rather than ISO A4. */ +const LETTER_REGIONS = new Set(["US", "CA", "MX", "PH", "CL", "CO", "VE", "PR"]); + +/** Best guess at the paper in the user's printer, used until they pick one. */ +export function guessPaperSize(locales: readonly string[] = navigator.languages ?? []): PaperSize { + for (const locale of locales) { + const region = locale.split("-").at(-1)?.toUpperCase(); + if (region && LETTER_REGIONS.has(region)) return "letter"; + } + return defaultPaperSize; +} + +/** The @page rule for the chosen paper. Emitted into the document so it lands + * after styles.css and overrides its A4 default. */ +function PaperStyle({ paper }: { paper: PaperSize }) { + const { size, margin } = PAPER_SIZES[paper]; + return ; +} + /** Full army-list printout with special rules, army rules, and spells. */ function entryExtras(army: ArmyData, entry: SavedUnitEntry | SavedCharacterEntry): string { return [ @@ -431,6 +469,7 @@ export default function PrintView({ list, army, duplexOffsetMm = 0, + paperSize = defaultPaperSize, cardOptions = defaultCardPrintOptions, scoutingEnabled = false, }: { @@ -440,6 +479,9 @@ export default function PrintView({ /** Horizontal nudge for the back pages (mm, positive = right) to calibrate * out the printer's front/back registration offset in duplex printing. */ duplexOffsetMm?: number; + /** Paper in the printer. Must match it, or the browser scales the sheet + * down to fit and the cards come out under size. */ + paperSize?: PaperSize; cardOptions?: CardPrintOptions; scoutingEnabled?: boolean; }) { @@ -448,6 +490,7 @@ export default function PrintView({ className="print-root" style={{ "--duplex-offset": `${duplexOffsetMm}mm` } as CSSProperties} > + {mode === "list" ? ( ) : ( diff --git a/src/styles.css b/src/styles.css index 06feb68..6373a06 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1157,12 +1157,25 @@ textarea { gap: 6px; } -.duplex-offset { +.duplex-offset, +.paper-size { display: flex; align-items: center; gap: 6px; } +.paper-size { + font-size: 13px; + color: #444; +} + +/* Cards only print at their true size at 100% scale, so say so next to the + options rather than leaving it to be discovered with a ruler. */ +.print-scale-hint { + font-style: italic; + color: #666; +} + .duplex-offset input { width: 64px; } @@ -1234,17 +1247,20 @@ textarea { /* ------------------------------------------------------------ unit cards */ -/* 63 x 88mm trading cards, 3 x 3 per A4 page; ink-light (borders only, no - fills). Every front page is followed by a back page whose columns are - mirrored (direction: rtl) so double-sided "flip on long edge" printing - lines each back up with its front. The fit-N levels must stay in sync - with FIT_LEVELS in src/domain/unitCard.ts. */ +/* 63 x 88mm trading cards - the same finished size as a Magic: the Gathering + card - 3 x 3 per page; ink-light (borders only, no fills). Every front page + is followed by a back page whose columns are mirrored (direction: rtl) so + double-sided "flip on long edge" printing lines each back up with its + front. The fit-N levels must stay in sync with FIT_LEVELS in + src/domain/unitCard.ts. The sheet's paper size comes from the @page rule + PrintView emits (see PAPER_SIZES there). */ .card-page { display: grid; grid-template-columns: repeat(3, 63mm); - /* Rows are the tight dimension: 3 x 88mm + 2 x 2mm = 268mm must fit the - printable page height (297 - 2 x 8mm margins) with slack to spare. */ - gap: 2mm 4mm; + /* Cards abut with no gutter so one straight cut separates two of them, and + so the block is only 189 x 264mm - the 264mm of rows is what lets three + rows fit US Letter's 279.4mm sheet at all (see PAPER_SIZES). */ + gap: 0; justify-content: center; break-after: page; margin-bottom: 6mm; @@ -1266,8 +1282,9 @@ textarea { position: relative; width: 63mm; height: 88mm; + /* Square corners, no radius: abutting cards then draw one continuous grid + of cut lines instead of leaving white notches where four corners meet. */ border: 1px solid #555; - border-radius: 2mm; padding: 2.5mm; overflow: hidden; break-inside: avoid; @@ -1466,6 +1483,8 @@ textarea { break-after: auto; } + /* Default sheet; PrintView emits a later @page rule for the paper size the + user picked in the print toolbar, which overrides this. */ @page { size: A4 portrait; margin: 8mm 6mm; diff --git a/tests/cards.spec.ts b/tests/cards.spec.ts index 27328e3..d52b256 100644 --- a/tests/cards.spec.ts +++ b/tests/cards.spec.ts @@ -6,10 +6,13 @@ import { expect, test } from "@playwright/test"; // rendering, the invariants the char-count fit model in // src/domain/unitCard.ts only estimates: // -// 1. every card is exactly 63 x 88mm; +// 1. every card is exactly 63 x 88mm - the finished size of a Magic: the +// Gathering card, which is only what comes out of the printer if the +// sheet is not scaled (see the paper-size test at the bottom); // 2. no card's content overflows its box (nothing gets clipped); // 3. no two text sections of a card overlap each other; -// 4. back pages mirror front pages column-wise, so double-sided printing +// 4. cards abut edge to edge, so one cut separates two of them; +// 5. back pages mirror front pages column-wise, so double-sided printing // with "flip on long edge" lines every back up with its front. interface Box { @@ -127,8 +130,13 @@ test("every card is 63 x 88mm with no overflowing or overlapping text", async ({ expect(overlapping.map((r) => `${r.id}/${r.face}: ${r.overlaps.join("; ")}`)).toEqual([]); }); -test("cards on a page do not overlap each other", async ({ page }) => { +test("cards on a page abut without overlapping each other", async ({ page }) => { + // Cards share edges so one cut separates two of them, so touching is the + // expected result here and only a real overlap - one card's content sitting + // on top of another's - is a failure. Sub-pixel rounding of the mm-based + // grid track positions means "touching" is never exactly 0, hence TOUCH. const collisions = await page.evaluate(() => { + const TOUCH = 0.5; // px const collisions: string[] = []; for (const sheet of document.querySelectorAll(".card-page")) { const cards = [...sheet.querySelectorAll(".unit-card")]; @@ -137,8 +145,13 @@ test("cards on a page do not overlap each other", async ({ page }) => { for (let j = i + 1; j < rects.length; j++) { const a = rects[i]; const b = rects[j]; - if (a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom) { - collisions.push(`${(cards[i] as HTMLElement).dataset.card} x ${(cards[j] as HTMLElement).dataset.card}`); + const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + if (overlapX > TOUCH && overlapY > TOUCH) { + collisions.push( + `${(cards[i] as HTMLElement).dataset.card} x ${(cards[j] as HTMLElement).dataset.card}` + + ` (${overlapX.toFixed(2)} x ${overlapY.toFixed(2)}px)`, + ); } } } @@ -148,6 +161,37 @@ test("cards on a page do not overlap each other", async ({ page }) => { expect(collisions).toEqual([]); }); +test("the 3 x 3 grid is 189 x 264mm of edge-to-edge cards", async ({ page }) => { + // The block has to stay this size: it is what fits three 88mm rows on US + // Letter (279.4mm) once PAPER_SIZES' 6mm margins are taken off. + const grid = await page.evaluate(() => { + const MM = 96 / 25.4; + const page1 = document.querySelector('[data-page="front-0"]')!; + const cards = [...page1.querySelectorAll(".unit-card")]; + const rects = cards.map((c) => c.getBoundingClientRect()); + const left = Math.min(...rects.map((r) => r.left)); + const right = Math.max(...rects.map((r) => r.right)); + const top = Math.min(...rects.map((r) => r.top)); + const bottom = Math.max(...rects.map((r) => r.bottom)); + // Gutters between neighbours in the 3-wide grid: card 1 sits beside card + // 0, card 3 sits below it. + const gapX = rects[1].left - rects[0].right; + const gapY = rects[3].top - rects[0].bottom; + return { + cards: cards.length, + widthMm: (right - left) / MM, + heightMm: (bottom - top) / MM, + gapXmm: gapX / MM, + gapYmm: gapY / MM, + }; + }); + expect(grid.cards).toBe(9); + expect(grid.widthMm).toBeCloseTo(189, 1); + expect(grid.heightMm).toBeCloseTo(264, 1); + expect(grid.gapXmm).toBeCloseTo(0, 1); + expect(grid.gapYmm).toBeCloseTo(0, 1); +}); + test("back pages mirror front pages for long-edge duplex printing", async ({ page }) => { const errors = await page.evaluate(() => { const errors: DuplexReport[] = []; @@ -206,7 +250,8 @@ test("printing from the app produces exactly one sheet per card page, no blanks" // Drive the real print flow: seeded list -> export dialog -> card preview. // Printing once produced 7 pages for a 2-pair sheet because the hidden app // behind the overlay kept its layout height (blank leading pages) and the - // 3 x 88mm rows only just fit the printable height. + // 3 x 88mm rows only just fit the printable height - still true on Letter, + // which the paper-size test below covers. const list = { id: "print-test", schemaVersion: 1, @@ -259,3 +304,53 @@ test("printing from the app produces exactly one sheet per card page, no blanks" const mmPx = 96 / 25.4; expect(after!.x - before!.x).toBeCloseTo(3 * mmPx, 0); }); + +test("each paper size prints its own sheet, so cards are never scaled down", async ({ page }) => { + // The bug this guards: the sheet was always declared A4, so printing it on + // US Letter made the browser shrink everything to 279.4/297 = 94% and the + // 63 x 88mm cards came out 59 x 83mm. Picking the paper in the toolbar has + // to change the @page size, and 3 x 3 cards must still fit each sheet. + const list = { + id: "paper-test", + schemaVersion: 1, + ruleSet: "warmaster-revolution", + ruleVersion: "2.2.6", + army: "empire", + name: "Paper Test", + pointsLimit: 2000, + notes: null, + updatedAt: new Date().toISOString(), + characters: [], + units: [ + { unitId: "empire:halberdiers", quantity: 4, upgrades: [], magicItems: [] }, + { unitId: "empire:crossbowmen", quantity: 3, upgrades: [], magicItems: [] }, + { unitId: "empire:knights", quantity: 2, upgrades: [], magicItems: [] }, + ], + }; + await page.addInitScript((l) => { + localStorage.setItem("warmuster.lists.v1", JSON.stringify([l])); + }, list); + await page.goto("/"); + await page.getByText("Paper Test").click(); + await page.getByRole("button", { name: /export/i }).click(); + await page.getByRole("button", { name: /cards/i }).click(); + await page.waitForSelector(".card-page"); + await page.evaluate(() => document.fonts.ready); + + // PDF MediaBox is in points (1/72in); 1mm = 72/25.4pt. + const PT = 72 / 25.4; + const paperMm = { A4: [210, 297], "US Letter": [215.9, 279.4] }; + for (const [label, [wMm, hMm]] of Object.entries(paperMm)) { + await page.getByLabel(/paper size/i).selectOption({ label }); + const pdf = await page.pdf({ preferCSSPageSize: true }); + const box = pdf.toString("latin1").match(/\/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)/); + expect(box, `${label}: no MediaBox in the PDF`).not.toBeNull(); + expect(Number(box![1]), `${label} width`).toBeCloseTo(wMm * PT, 0); + expect(Number(box![2]), `${label} height`).toBeCloseTo(hMm * PT, 0); + // 264mm of card rows plus the margins must still leave one sheet per page + // pair - if the rows spilled, the PDF would gain pages. + const pairs = await page.locator(".card-page-pair").count(); + const pdfPages = pdf.toString("latin1").match(/\/Type\s*\/Page[^s]/g)?.length ?? 0; + expect(pdfPages, `${label}: rows must not spill onto extra sheets`).toBe(pairs * 2); + } +}); From c4f1e53174006c23b932216d294cade85a3905ed Mon Sep 17 00:00:00 2001 From: tpskowski <98412204+tpskowski@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:10:44 -0400 Subject: [PATCH 2/2] Add a card gap option to the print toolbar Abutting cards share a cut line, which is the fastest way to cut a sheet but leaves no margin for a blade that wanders. "Card gap" opens a gutter between them instead, so each card gets its own edge. The gutter is clamped to what the chosen paper can still fit 3 x 3 cards in: A4 has 8.5mm of vertical slack to spend and allows 4.5mm, Letter has 1.7mm and allows 1.5mm. Switching paper re-clamps the stored value, so moving an A4 gutter to Letter can never push a row onto a fourth page. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 38 +++++++++++++++ src/components/PrintView.test.tsx | 22 ++++++++- src/components/PrintView.tsx | 48 +++++++++++++++++-- src/styles.css | 13 +++-- tests/cards.spec.ts | 79 +++++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6f05e72..7a82a18 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,8 +7,10 @@ import ListRail, { InfoLinks } from "./components/ListRail"; import { HamburgerIcon, MoonIcon, SunIcon } from "./components/Icons"; import MagicItemsDialog from "./components/MagicItemsDialog"; import PrintView, { + clampCardGutterMm, defaultCardPrintOptions, guessPaperSize, + maxCardGutterMm, PAPER_SIZES, type CardPrintOptions, type PaperSize, @@ -173,6 +175,24 @@ export default function App() { localStorage.setItem("warmuster.paperSize", paperSize); }, [paperSize]); + // Gutter between printed cards (mm). 0 abuts them, so neighbours share a + // cut line; a gutter gives each card its own edge at the cost of sheet + // room, which Letter has very little of (see maxCardGutterMm). + const [cardGutter, setCardGutter] = useState(() => { + const stored = Number(localStorage.getItem("warmuster.cardGutter")); + return Number.isFinite(stored) ? stored : 0; + }); + const gutterMax = maxCardGutterMm(paperSize); + // Switching to the smaller sheet has to pull an over-wide gutter back in, + // or a row would spill onto an extra page. + useEffect(() => { + setCardGutter((current) => clampCardGutterMm(current, paperSize)); + }, [paperSize]); + + useEffect(() => { + localStorage.setItem("warmuster.cardGutter", String(cardGutter)); + }, [cardGutter]); + // The print preview lives on its own history entry, so the browser Back // button closes it (returning to the app) instead of leaving the site. const openPrint = (mode: PrintMode) => { @@ -527,6 +547,23 @@ export default function App() { /> mm + Cards are 63 × 88 mm; print at 100% scale (not “fit to page”). @@ -539,6 +576,7 @@ export default function App() { army={army} duplexOffsetMm={printMode === "cards" ? duplexOffset : 0} paperSize={paperSize} + cardGutterMm={printMode === "cards" ? cardGutter : 0} cardOptions={cardPrintOptions} scoutingEnabled={scoutingEnabled} /> diff --git a/src/components/PrintView.test.tsx b/src/components/PrintView.test.tsx index fe073de..5187d7c 100644 --- a/src/components/PrintView.test.tsx +++ b/src/components/PrintView.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { getArmy } from "../data/gameData"; import { addUnit, createList, toggleUnitScouting } from "../domain/lists"; import { totalScoutingPoints } from "../domain/scouting"; -import { PrintList } from "./PrintView"; +import { clampCardGutterMm, maxCardGutterMm, PrintList } from "./PrintView"; describe("PrintList", () => { it("prints the breakpoint and unit type", () => { @@ -39,3 +39,23 @@ describe("PrintList", () => { expect(within(table).getAllByText(/^✓ \d+$/)).toHaveLength(1); }); }); + +describe("card gutter", () => { + // Three 88mm rows are 264mm: A4 has 281mm of printable height to spend, + // Letter only 267.4mm, so Letter is what limits the gutter. + it("offers the widest gutter each paper can still fit 3 x 3 cards in", () => { + expect(maxCardGutterMm("a4")).toBe(4.5); + expect(maxCardGutterMm("letter")).toBe(1.5); + }); + + it("clamps a gutter to the chosen paper so a row can never spill", () => { + expect(clampCardGutterMm(0, "letter")).toBe(0); + expect(clampCardGutterMm(1.5, "letter")).toBe(1.5); + // An A4-sized gutter carried over to Letter comes back to Letter's max. + expect(clampCardGutterMm(4.5, "letter")).toBe(1.5); + expect(clampCardGutterMm(4.5, "a4")).toBe(4.5); + expect(clampCardGutterMm(99, "a4")).toBe(4.5); + expect(clampCardGutterMm(-3, "a4")).toBe(0); + expect(clampCardGutterMm(Number.NaN, "a4")).toBe(0); + }); +}); diff --git a/src/components/PrintView.tsx b/src/components/PrintView.tsx index 40d722a..b4582f0 100644 --- a/src/components/PrintView.tsx +++ b/src/components/PrintView.tsx @@ -42,14 +42,42 @@ export const defaultCardPrintOptions: CardPrintOptions = { * for both margins — hence the tighter 6mm here. A printer whose unprintable * border is wider than that cannot fit 3 x 3 cards on Letter at full size. */ export const PAPER_SIZES = { - a4: { label: "A4", size: "A4 portrait", margin: "8mm 6mm" }, - letter: { label: "US Letter", size: "Letter portrait", margin: "6mm" }, + a4: { label: "A4", size: "A4 portrait", widthMm: 210, heightMm: 297, marginXMm: 6, marginYMm: 8 }, + letter: { + label: "US Letter", + size: "Letter portrait", + widthMm: 215.9, + heightMm: 279.4, + marginXMm: 6, + marginYMm: 6, + }, } as const; export type PaperSize = keyof typeof PAPER_SIZES; export const defaultPaperSize: PaperSize = "a4"; +/** One card, and the grid they print in. Keep in step with styles.css. */ +export const CARD_MM = { width: 63, height: 88, columns: 3, rows: 3 }; + +/** Widest gutter between cards that still fits 3 x 3 on a sheet of `paper`, + * rounded down to the nearest 0.5mm. Letter is the binding case: three 88mm + * rows leave it only 1.7mm of slack, against 8.5mm on A4. */ +export function maxCardGutterMm(paper: PaperSize): number { + const { widthMm, heightMm, marginXMm, marginYMm } = PAPER_SIZES[paper]; + const slackX = widthMm - 2 * marginXMm - CARD_MM.columns * CARD_MM.width; + const slackY = heightMm - 2 * marginYMm - CARD_MM.rows * CARD_MM.height; + const gutter = Math.min(slackX / (CARD_MM.columns - 1), slackY / (CARD_MM.rows - 1)); + return Math.max(0, Math.floor(gutter * 2) / 2); +} + +/** A gutter the chosen paper can actually take, so switching to the smaller + * sheet can never push a row onto an extra page. */ +export function clampCardGutterMm(gutterMm: number, paper: PaperSize): number { + if (!Number.isFinite(gutterMm)) return 0; + return Math.min(Math.max(gutterMm, 0), maxCardGutterMm(paper)); +} + /** Regions that use US Letter rather than ISO A4. */ const LETTER_REGIONS = new Set(["US", "CA", "MX", "PH", "CL", "CO", "VE", "PR"]); @@ -65,8 +93,10 @@ export function guessPaperSize(locales: readonly string[] = navigator.languages /** The @page rule for the chosen paper. Emitted into the document so it lands * after styles.css and overrides its A4 default. */ function PaperStyle({ paper }: { paper: PaperSize }) { - const { size, margin } = PAPER_SIZES[paper]; - return ; + const { size, marginXMm, marginYMm } = PAPER_SIZES[paper]; + return ( + + ); } /** Full army-list printout with special rules, army rules, and spells. */ @@ -470,6 +500,7 @@ export default function PrintView({ army, duplexOffsetMm = 0, paperSize = defaultPaperSize, + cardGutterMm = 0, cardOptions = defaultCardPrintOptions, scoutingEnabled = false, }: { @@ -482,13 +513,20 @@ export default function PrintView({ /** Paper in the printer. Must match it, or the browser scales the sheet * down to fit and the cards come out under size. */ paperSize?: PaperSize; + /** Gutter between cards. 0 abuts them, so neighbours share a cut line. */ + cardGutterMm?: number; cardOptions?: CardPrintOptions; scoutingEnabled?: boolean; }) { return (
{mode === "list" ? ( diff --git a/src/styles.css b/src/styles.css index 6373a06..107e3e8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1158,6 +1158,7 @@ textarea { } .duplex-offset, +.card-gutter, .paper-size { display: flex; align-items: center; @@ -1176,7 +1177,8 @@ textarea { color: #666; } -.duplex-offset input { +.duplex-offset input, +.card-gutter input { width: 64px; } @@ -1257,10 +1259,11 @@ textarea { .card-page { display: grid; grid-template-columns: repeat(3, 63mm); - /* Cards abut with no gutter so one straight cut separates two of them, and - so the block is only 189 x 264mm - the 264mm of rows is what lets three - rows fit US Letter's 279.4mm sheet at all (see PAPER_SIZES). */ - gap: 0; + /* Cards abut by default so one straight cut separates two of them, and so + the block is only 189 x 264mm - the 264mm of rows is what lets three rows + fit US Letter's 279.4mm sheet at all (see PAPER_SIZES). The print toolbar + can open a gutter, clamped to what the chosen paper still fits. */ + gap: var(--card-gutter, 0); justify-content: center; break-after: page; margin-bottom: 6mm; diff --git a/tests/cards.spec.ts b/tests/cards.spec.ts index d52b256..37baf0e 100644 --- a/tests/cards.spec.ts +++ b/tests/cards.spec.ts @@ -354,3 +354,82 @@ test("each paper size prints its own sheet, so cards are never scaled down", asy expect(pdfPages, `${label}: rows must not spill onto extra sheets`).toBe(pairs * 2); } }); + +test("the card gap opens a gutter, clamped to what the paper still fits", async ({ page }) => { + const list = { + id: "gutter-test", + schemaVersion: 1, + ruleSet: "warmaster-revolution", + ruleVersion: "2.2.6", + army: "empire", + name: "Gutter Test", + pointsLimit: 2000, + notes: null, + updatedAt: new Date().toISOString(), + characters: [ + { id: "c1", unitId: "empire:general", upgrades: [], magicItems: [] }, + { id: "c2", unitId: "empire:wizard", upgrades: [], magicItems: [] }, + ], + units: [ + { unitId: "empire:halberdiers", quantity: 4, upgrades: [], magicItems: [] }, + { unitId: "empire:crossbowmen", quantity: 3, upgrades: [], magicItems: [] }, + { unitId: "empire:knights", quantity: 2, upgrades: [], magicItems: [] }, + { unitId: "empire:cannon", quantity: 1, upgrades: [], magicItems: [] }, + { unitId: "empire:helblaster", quantity: 1, upgrades: [], magicItems: [] }, + { unitId: "empire:pistoliers", quantity: 1, upgrades: [], magicItems: [] }, + { unitId: "empire:flagellants", quantity: 1, upgrades: [], magicItems: [] }, + ], + }; + await page.addInitScript((l) => { + localStorage.setItem("warmuster.lists.v1", JSON.stringify([l])); + }, list); + await page.goto("/"); + await page.getByText("Gutter Test").click(); + await page.getByRole("button", { name: /export/i }).click(); + await page.getByRole("button", { name: /cards/i }).click(); + await page.waitForSelector(".card-page"); + await page.evaluate(() => document.fonts.ready); + + const gutters = () => + page.evaluate(() => { + const MM = 96 / 25.4; + const cards = [ + ...document.querySelector('[data-page="front-0"]')!.querySelectorAll(".unit-card"), + ].map((c) => c.getBoundingClientRect()); + return { + x: +((cards[1].left - cards[0].right) / MM).toFixed(2), + y: +((cards[3].top - cards[0].bottom) / MM).toFixed(2), + }; + }); + const sheets = async () => { + const pdf = await page.pdf({ preferCSSPageSize: true }); + return pdf.toString("latin1").match(/\/Type\s*\/Page[^s]/g)?.length ?? 0; + }; + + const paper = page.getByLabel(/paper size/i); + const gap = page.getByLabel(/card gap/i); + const pairs = await page.locator(".card-page-pair").count(); + + // Default is edge to edge. + expect(await gutters()).toEqual({ x: 0, y: 0 }); + + // A4 takes the widest gutter without gaining a sheet. + await paper.selectOption({ label: "A4" }); + await gap.fill("4.5"); + await gap.blur(); + expect(await gutters()).toEqual({ x: 4.5, y: 4.5 }); + expect(await sheets(), "A4 at its max gutter must still be one sheet per page").toBe(pairs * 2); + + // Letter has 1.7mm of slack for 3 rows, so it pulls the gutter back to 1.5 + // rather than pushing a row onto a fourth page. + await paper.selectOption({ label: "US Letter" }); + expect(await gutters()).toEqual({ x: 1.5, y: 1.5 }); + expect(await sheets(), "Letter at its max gutter must still be one sheet per page").toBe( + pairs * 2, + ); + + // The field cannot be pushed past that maximum by typing either. + await gap.fill("9"); + await gap.blur(); + expect(await gutters()).toEqual({ x: 1.5, y: 1.5 }); +});