loopable / web /src /customer-grid /catalogPrint.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
4748aae verified
Raw
History Blame Contribute Delete
6.49 kB
// ---------------------------------------------------------------------------
// 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.
});
}