Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ 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,
type PrintMode,
} from "./components/PrintView";
import Roster from "./components/Roster";
Expand Down Expand Up @@ -158,6 +163,36 @@ 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<PaperSize>(() => {
const stored = localStorage.getItem("warmuster.paperSize");
return stored != null && stored in PAPER_SIZES ? (stored as PaperSize) : guessPaperSize();
});

useEffect(() => {
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<number>(() => {
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) => {
Expand Down Expand Up @@ -455,6 +490,20 @@ export default function App() {
<button type="button" onClick={closePrint}>
Close preview
</button>
<label className="paper-size" title="Must match the paper in the printer, or the browser scales the sheet down to fit it.">
Paper
<select
aria-label="Paper size"
value={paperSize}
onChange={(event) => setPaperSize(event.target.value as PaperSize)}
>
{Object.entries(PAPER_SIZES).map(([value, paper]) => (
<option key={value} value={value}>
{paper.label}
</option>
))}
</select>
</label>
{printMode === "cards" && (
<div className="card-print-options">
<label>
Expand Down Expand Up @@ -498,6 +547,26 @@ export default function App() {
/>
mm
</label>
<label
className="card-gutter"
title={`Space between cards. 0 abuts them so one cut separates two cards; ${gutterMax}mm is the most ${PAPER_SIZES[paperSize].label} fits.`}
>
Card gap
<input
type="number"
step={0.5}
min={0}
max={gutterMax}
value={cardGutter}
onChange={(e) =>
setCardGutter(clampCardGutterMm(Number(e.target.value), paperSize))
}
/>
mm (max {gutterMax})
</label>
<span className="print-scale-hint">
Cards are 63 &times; 88 mm; print at 100% scale (not &ldquo;fit to page&rdquo;).
</span>
</div>
)}
</div>
Expand All @@ -506,6 +575,8 @@ export default function App() {
list={activeList}
army={army}
duplexOffsetMm={printMode === "cards" ? duplexOffset : 0}
paperSize={paperSize}
cardGutterMm={printMode === "cards" ? cardGutter : 0}
cardOptions={cardPrintOptions}
scoutingEnabled={scoutingEnabled}
/>
Expand Down
22 changes: 21 additions & 1 deletion src/components/PrintView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
83 changes: 82 additions & 1 deletion src/components/PrintView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,74 @@ 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", 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"]);

/** 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, marginXMm, marginYMm } = PAPER_SIZES[paper];
return (
<style>{`@media print{@page{size:${size};margin:${marginYMm}mm ${marginXMm}mm;}}`}</style>
);
}

/** Full army-list printout with special rules, army rules, and spells. */
function entryExtras(army: ArmyData, entry: SavedUnitEntry | SavedCharacterEntry): string {
return [
Expand Down Expand Up @@ -431,6 +499,8 @@ export default function PrintView({
list,
army,
duplexOffsetMm = 0,
paperSize = defaultPaperSize,
cardGutterMm = 0,
cardOptions = defaultCardPrintOptions,
scoutingEnabled = false,
}: {
Expand All @@ -440,14 +510,25 @@ 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;
/** Gutter between cards. 0 abuts them, so neighbours share a cut line. */
cardGutterMm?: number;
cardOptions?: CardPrintOptions;
scoutingEnabled?: boolean;
}) {
return (
<div
className="print-root"
style={{ "--duplex-offset": `${duplexOffsetMm}mm` } as CSSProperties}
style={
{
"--duplex-offset": `${duplexOffsetMm}mm`,
"--card-gutter": `${clampCardGutterMm(cardGutterMm, paperSize)}mm`,
} as CSSProperties
}
>
<PaperStyle paper={paperSize} />
{mode === "list" ? (
<PrintList list={list} army={army} scoutingEnabled={scoutingEnabled} />
) : (
Expand Down
44 changes: 33 additions & 11 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1157,13 +1157,28 @@ textarea {
gap: 6px;
}

.duplex-offset {
.duplex-offset,
.card-gutter,
.paper-size {
display: flex;
align-items: center;
gap: 6px;
}

.duplex-offset input {
.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,
.card-gutter input {
width: 64px;
}

Expand Down Expand Up @@ -1234,17 +1249,21 @@ 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 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;
Expand All @@ -1266,8 +1285,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;
Expand Down Expand Up @@ -1466,6 +1486,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;
Expand Down
Loading