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.
  });
}