| // --------------------------------------------------------------------------- | |
| // customer-grid / catalogPrint.ts | |
| // Wave-18 owner item 4, ruling R10 — catalog EXPORT is browser print-to-PDF: | |
| // true paginated pages, a real `@page` sized from the catalog's own paper | |
| // setting, and page-break rules so ONE catalog prints and nothing else does. | |
| // | |
| // Why a generated stylesheet instead of CSS in index.css: `@page { size: … }` | |
| // cannot read a custom property. The page size is DATA (per catalog, per | |
| // orientation), so the only way to state it is to write the rule at print time. | |
| // index.css carries everything static; this module carries the one rule that | |
| // changes with the artifact. | |
| // | |
| // ⚠ The gate that proves this passes `preferCSSPageSize: True` to Playwright's | |
| // `page.pdf()`. Without it Playwright IGNORES `@page { size }` and emits Letter | |
| // whatever the rule says — a green check that proves the default, not the | |
| // contract ([[gate-can-report-green-on-nothing]]). | |
| // --------------------------------------------------------------------------- | |
| import { cssLength, pageBox } from "./catalogData"; | |
| import type { CatalogSpec } from "./types"; | |
| /** The `<style>` element's id — one per document, replaced rather than stacked. */ | |
| export const PRINT_STYLE_ID = "cg-cat-print-style"; | |
| /** Set on `<html>` for the duration of a print, and removed after. The static | |
| * half of the isolation lives in index.css keyed off this class. */ | |
| export const PRINTING_CLASS = "cg-cat-printing"; | |
| /** | |
| * The catalog's print stylesheet. | |
| * | |
| * Dimensions are EXPLICIT (`8.5in 11in`), never the CSS page-size keywords: the | |
| * keyword vocabulary has no unambiguous name for 11x17 (`ledger` vs `tabloid` | |
| * differ by rotation depending on the engine), and an explicit pair is what a | |
| * PDF can be measured against. | |
| * | |
| * `print-color-adjust: exact` is load-bearing, not a nicety — without it every | |
| * engine drops the navy pack pill, the navy cover field and the terracotta rules | |
| * to white, which is the entire visual language of the reference catalogue. | |
| */ | |
| export function printCss(cat: CatalogSpec): string { | |
| const box = pageBox(cat.paper, cat.orientation); | |
| const w = cssLength(box.w, box.unit); | |
| const h = cssLength(box.h, box.unit); | |
| const R = `html.${PRINTING_CLASS}`; | |
| return [ | |
| `@page { size: ${w} ${h}; margin: 0; }`, | |
| `@media print {`, | |
| ` ${R}, ${R} body {`, | |
| ` width: ${w}; margin: 0; padding: 0; background: #fff;`, | |
| ` -webkit-print-color-adjust: exact; print-color-adjust: exact;`, | |
| ` }`, | |
| ` ${R} .cg-cat-page {`, | |
| ` width: ${w}; height: ${h};`, | |
| ` margin: 0; box-shadow: none; border: 0; border-radius: 0;`, | |
| ` transform: none; break-after: page; break-inside: avoid;`, | |
| ` }`, | |
| // A trailing `break-after: page` is how a print job grows one blank sheet at | |
| // the end — the break is honoured, then there is nothing to put on it. | |
| ` ${R} .cg-cat-page:last-child { break-after: auto; }`, | |
| `}`, | |
| ].join("\n"); | |
| } | |
| /** Write (or rewrite) the catalog's print rule into the document. Returns a | |
| * remover, so a cancelled print leaves no sizing behind for the next one. */ | |
| export function applyPrintCss(cat: CatalogSpec, doc: Document = document): () => void { | |
| let el = doc.getElementById(PRINT_STYLE_ID) as HTMLStyleElement | null; | |
| if (!el) { | |
| el = doc.createElement("style"); | |
| el.id = PRINT_STYLE_ID; | |
| doc.head.appendChild(el); | |
| } | |
| el.textContent = printCss(cat); | |
| return () => { | |
| el?.parentNode?.removeChild(el); | |
| }; | |
| } | |
| /** | |
| * Wait for every image under `root` to be decoded. | |
| * | |
| * R9's quality swap changes every `src` in the tree; calling `window.print()` on | |
| * the next line prints the pages with the NEW urls still loading, i.e. blank | |
| * frames — the browser does not wait for images before opening the dialog. This | |
| * is the step that makes "high quality" mean anything. | |
| * | |
| * Never rejects: a 404 image (a product with no master) must not stop the print. | |
| * `decode()` is what rejects on those, so each is caught individually. | |
| */ | |
| export async function awaitImages(root: ParentNode | null, timeoutMs = 20_000): Promise<void> { | |
| if (!root) return; | |
| const imgs = Array.from(root.querySelectorAll("img")); | |
| if (!imgs.length) return; | |
| const all = Promise.all( | |
| imgs.map((img) => | |
| img.decode | |
| ? img.decode().catch(() => undefined) | |
| : new Promise<void>((res) => { | |
| if (img.complete) return res(); | |
| img.addEventListener("load", () => res(), { once: true }); | |
| img.addEventListener("error", () => res(), { once: true }); | |
| }) | |
| ) | |
| ).then(() => undefined); | |
| // The ceiling is a real one: a stalled asset route must delay the dialog, not | |
| // cancel the export. | |
| await Promise.race([all, new Promise<void>((res) => setTimeout(res, timeoutMs))]); | |
| } | |
| /** | |
| * Add the printing class, open the dialog, and take the class back off however | |
| * the dialog ends. | |
| * | |
| * THREE ways out, because none of them is reliable alone: `afterprint` (does not | |
| * fire on a cancelled dialog in every engine), the `print` media query going | |
| * false (Safari's route), and a timeout (the honest backstop). `finish` is | |
| * idempotent, so whichever arrives first wins and the rest are no-ops — leaving | |
| * the class on would hide the entire app behind a print rule. | |
| */ | |
| export function printOnce( | |
| doc: Document = document, | |
| print?: () => void | |
| ): Promise<void> { | |
| const win = doc.defaultView; | |
| const root = doc.documentElement; | |
| return new Promise<void>((resolve) => { | |
| let done = false; | |
| let timer: ReturnType<typeof setTimeout> | undefined; | |
| const mql = win?.matchMedia ? win.matchMedia("print") : null; | |
| const onMql = (e: MediaQueryListEvent) => { | |
| if (!e.matches) finish(); | |
| }; | |
| function finish() { | |
| if (done) return; | |
| done = true; | |
| root.classList.remove(PRINTING_CLASS); | |
| win?.removeEventListener("afterprint", finish); | |
| mql?.removeEventListener?.("change", onMql); | |
| if (timer !== undefined) clearTimeout(timer); | |
| resolve(); | |
| } | |
| root.classList.add(PRINTING_CLASS); | |
| win?.addEventListener("afterprint", finish); | |
| mql?.addEventListener?.("change", onMql); | |
| timer = setTimeout(finish, 120_000); | |
| try { | |
| if (print) print(); | |
| else win?.print(); | |
| } catch { | |
| finish(); | |
| } | |
| // Chrome's `window.print()` blocks until the dialog closes and only THEN | |
| // fires afterprint; Firefox returns immediately. Both are handled above. | |
| }); | |
| } | |