File size: 6,487 Bytes
4748aae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | // ---------------------------------------------------------------------------
// 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.
});
}
|