@ai-matrx/print 0.12.39 → 0.12.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -1
- package/dist/booklet.cjs +19 -0
- package/dist/booklet.cjs.map +1 -1
- package/dist/booklet.js +19 -0
- package/dist/booklet.js.map +1 -1
- package/dist/certificate.cjs +19 -0
- package/dist/certificate.cjs.map +1 -1
- package/dist/certificate.js +19 -0
- package/dist/certificate.js.map +1 -1
- package/dist/core.cjs +26 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +20 -1
- package/dist/core.d.ts +20 -1
- package/dist/core.js +26 -1
- package/dist/core.js.map +1 -1
- package/dist/education.cjs +19 -0
- package/dist/education.cjs.map +1 -1
- package/dist/education.js +19 -0
- package/dist/education.js.map +1 -1
- package/dist/exam.cjs +19 -0
- package/dist/exam.cjs.map +1 -1
- package/dist/exam.js +19 -0
- package/dist/exam.js.map +1 -1
- package/dist/flashcards.cjs +19 -0
- package/dist/flashcards.cjs.map +1 -1
- package/dist/flashcards.js +19 -0
- package/dist/flashcards.js.map +1 -1
- package/dist/labels.cjs +19 -0
- package/dist/labels.cjs.map +1 -1
- package/dist/labels.js +19 -0
- package/dist/labels.js.map +1 -1
- package/dist/markdown.cjs +21 -1
- package/dist/markdown.cjs.map +1 -1
- package/dist/markdown.js +21 -1
- package/dist/markdown.js.map +1 -1
- package/dist/react.cjs +19 -0
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +19 -0
- package/dist/react.js.map +1 -1
- package/dist/zpl.cjs +19 -0
- package/dist/zpl.cjs.map +1 -1
- package/dist/zpl.js +19 -0
- package/dist/zpl.js.map +1 -1
- package/package.json +4 -4
package/dist/certificate.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/core.ts","../src/katex-css.generated.ts","../src/certificate.ts","../src/booklet.ts"],"sourcesContent":["/**\n * @ai-matrx/print/core — the Block Print contract & print-window composition.\n *\n * Ported VERBATIM from matrx-frontend `lib/block-print/block-print-utils.ts`\n * (2026-08-29 state, including the `number` and `select` PrintSetting types the\n * commerce label lane added). Zero dependencies; touches `window` only at call\n * time, so the module stays import-inert and RSC-safe.\n *\n * BlockPrinter is the only interface a host system needs to know about.\n * Each printable unit exports its own BlockPrinter alongside its render\n * component; host code only ever calls print(data, variantId, settings).\n *\n * `buildFitTextScript` is the ONE auto-shrink script (the flashcards\n * FIT_TEXT pattern, parameterized by selector so the label lane shares it).\n */\n\n// THE ONE HTML escaper (duplication census row 14). Nine copies of\n// `escapeHtml` were live across the fleet and THEY DID NOT ESCAPE THE SAME\n// CHARACTERS — a string safe through one was an attribute break-out through\n// another. It moved to `@ai-matrx/kit/html-escape` with a decided character\n// set: an HTML escaper must not require this package's jspdf + html2canvas\n// graph. Not re-exported from here; import `@ai-matrx/kit/html-escape`.\nimport { downloadFile } from \"@ai-matrx/kit/download\";\nimport { escapeHtml } from \"@ai-matrx/kit/html-escape\";\nimport { KATEX_CSS, KATEX_CSS_MARKER } from \"./katex-css.generated\";\nimport { openOutbound } from \"@ai-matrx/kit/outbound\";\n\n// ─── Contract ────────────────────────────────────────────────────────────────\n\nexport interface PrintVariant {\n id: string;\n label: string;\n description?: string;\n}\n\n/**\n * A single user-configurable setting shown in the print dialog.\n * Extend the discriminated union for new control types\n * without breaking existing printers.\n */\nexport type PrintSetting =\n | {\n type: \"boolean\";\n id: string;\n label: string;\n description?: string;\n /** Default value shown when the dialog first opens */\n defaultValue: boolean;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /**\n * A numeric \"from → to\" range control, rendered as two small number inputs.\n * Writes two entries into the resolved PrintSettings map — one under fromId,\n * one under toId. Printers interpret 0 (or missing) as \"no bound\" by convention.\n */\n type: \"range\";\n /** Stable id used as React key — not written to the settings map */\n id: string;\n label: string;\n description?: string;\n /** Settings map key for the lower bound value */\n fromId: string;\n /** Settings map key for the upper bound value */\n toId: string;\n defaultFrom: number;\n defaultTo: number;\n /** Minimum accepted value for each input (inclusive). Default 0. */\n min?: number;\n /** Placeholder shown when the input is empty (0). Default \"start\" / \"end\". */\n fromPlaceholder?: string;\n toPlaceholder?: string;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /** A single numeric input (e.g. \"start at label #\", \"copies\"). */\n type: \"number\";\n id: string;\n label: string;\n description?: string;\n defaultValue: number;\n /** Minimum accepted value (inclusive). Default 0. */\n min?: number;\n /** Maximum accepted value (inclusive). Omit = unbounded. */\n max?: number;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /** A one-of-N choice rendered as a compact segmented row. */\n type: \"select\";\n id: string;\n label: string;\n description?: string;\n options: { value: string; label: string }[];\n defaultValue: string;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n };\n\n/** A resolved map of setting id → current value, passed to print(). */\nexport type PrintSettings = Record<string, boolean | string | number>;\n\n/**\n * The self-contained print capability that each block can export.\n * Host code only ever calls print(data, variantId, settings) — it never inspects internals.\n */\nexport interface BlockPrinter {\n /** Tooltip / aria-label for the print button in the block header */\n label: string;\n\n /**\n * Variants offered to the user before printing.\n * Empty array = no options dialog shown; prints immediately with defaults.\n */\n variants: PrintVariant[];\n\n /**\n * Optional user-configurable settings shown below the variant picker.\n * Omit entirely if the block has no configurable settings.\n */\n settings?: PrintSetting[];\n\n /**\n * Execute the print.\n * data — the block's structured data (serverData or parsed content)\n * variantId — which variant was chosen (undefined = default / no variants)\n * settings — resolved setting values chosen by the user\n *\n * MAY resolve to a PrintOutcome (\"opened\" | \"downloaded\") so the host can\n * tell the user when the popup-blocked download fallback fired; `void`\n * stays legal for printers that don't go through a print window.\n */\n print: (\n data: unknown,\n variantId?: string,\n settings?: PrintSettings,\n ) => void | PrintOutcome | Promise<void | PrintOutcome>;\n\n /**\n * The block as it appears INSIDE a composed print (a message's Print / Full\n * Print, a document export): the same adapter the block's own print uses,\n * returning body HTML or a picture. A frame block (an HTML page) answers\n * with an image — a composed print window runs no author scripts — or an\n * honest `{ notice }` when no picture can be made. Absent ⇒ the composer\n * prints the block's source.\n */\n toPrintHtml?: (data: unknown, context: PrintBlockContext) => PrintBlockOutput | null | Promise<PrintBlockOutput | null>;\n}\n\n/** Where a block sits when a composer asks for its print form. */\nexport interface PrintBlockContext {\n /** The registry key the block was found under (artifact type or `__kind`). */\n readonly type: string;\n /** The block's raw source text, exactly as it sits in the message. */\n readonly raw: string;\n /** The block's title, when the wire form carries one. */\n readonly title?: string | undefined;\n /** The wire form's attributes (an `<artifact>`'s `id`, `version`) — how a printer finds the block's record. */\n readonly attributes?: Readonly<Record<string, string>> | undefined;\n}\n\n/**\n * A block's print form inside a composed print. `html` is trusted body HTML\n * from a printer (escape what you interpolate); `image` is a picture (a\n * captured page, a drawn diagram); `notice` is an honest line printed in the\n * block's place when neither exists.\n */\nexport type PrintBlockOutput =\n | {\n readonly html: string;\n /**\n * The block's stylesheet. A composed print sanitizes block HTML (no\n * `<style>`, no inline styles, only `matrx-*` / `print-*` classes), so a\n * printer that styles its layout passes its CSS here: the composer puts\n * each distinct sheet once in the document head. Scope it under your own\n * `matrx-…` class.\n */\n readonly css?: string | undefined;\n }\n | { readonly image: { readonly src: string; readonly alt?: string | undefined; readonly caption?: string | undefined } }\n | { readonly notice: string };\n\n// ─── The ONE block-printer registry ───────────────────────────────────────────\n//\n// Keyed by artifact type / `__kind`. A block's own print button, the canvas\n// tab's Print and the message composer all read the same entry, so a block\n// has one print adapter with every caller. Lives on globalThis under a\n// Symbol.for key so duplicated bundles (ESM + CJS) share one registry.\n\nconst PRINTER_REGISTRY = Symbol.for(\"ai-matrx.print.block-printers\");\n\nfunction printerRegistry(): Map<string, BlockPrinter> {\n const g = globalThis as unknown as { [PRINTER_REGISTRY]?: Map<string, BlockPrinter> };\n g[PRINTER_REGISTRY] ??= new Map();\n return g[PRINTER_REGISTRY];\n}\n\n/** Registers `printer` under each key (artifact type and/or `__kind`). Returns the unregister. */\nexport function registerBlockPrinter(keys: string | readonly string[], printer: BlockPrinter): () => void {\n const map = printerRegistry();\n const list = (typeof keys === \"string\" ? [keys] : keys).map((key) => key.toLowerCase());\n for (const key of list) map.set(key, printer);\n return () => {\n for (const key of list) if (map.get(key) === printer) map.delete(key);\n };\n}\n\n/** The printer registered for an artifact type or `__kind` (case-insensitive). */\nexport function getBlockPrinter(key: string | null | undefined): BlockPrinter | undefined {\n return key ? printerRegistry().get(key.toLowerCase()) : undefined;\n}\n\n// ─── Lazy printer loaders ─────────────────────────────────────────────────────\n//\n// A host may load its printers lazily (they carry KaTeX and every structured\n// layout, so they stay out of a page's first load). A lookup that is allowed to\n// wait — a message print, which already awaits its own imports — calls\n// `whenBlockPrintersReady()` first, so a print clicked before the host's\n// background load finishes still finds every printer instead of silently\n// printing a block as its source. Shared on globalThis like the registry.\n\nconst PRINTER_LOADERS = Symbol.for(\"ai-matrx.print.block-printer-loaders\");\n\nfunction printerLoaders(): Set<() => Promise<unknown>> {\n const g = globalThis as unknown as { [PRINTER_LOADERS]?: Set<() => Promise<unknown>> };\n g[PRINTER_LOADERS] ??= new Set();\n return g[PRINTER_LOADERS];\n}\n\n/** Registers a host's lazy printer loader (it must be idempotent). Returns the unregister. */\nexport function registerBlockPrinterLoader(load: () => Promise<unknown>): () => void {\n const loaders = printerLoaders();\n loaders.add(load);\n return () => {\n loaders.delete(load);\n };\n}\n\n/**\n * Resolves once every registered printer loader has finished. A loader that\n * fails is reported, never thrown: the print still goes out, with that block\n * printed as its source.\n */\nexport async function whenBlockPrintersReady(): Promise<void> {\n await Promise.all(\n [...printerLoaders()].map((load) =>\n load().then(\n () => undefined,\n (error: unknown) => {\n console.error(\"[print] a block-printer loader failed; its blocks print as source\", error);\n },\n ),\n ),\n );\n}\n\n/** Every registered key — for guards and diagnostics. */\nexport function listBlockPrinterKeys(): string[] {\n return [...printerRegistry().keys()];\n}\n\n/** The HTML a composer puts in a block's place for a `PrintBlockOutput`. */\nexport function printBlockOutputHtml(output: PrintBlockOutput): string {\n if (\"html\" in output) return `<div class=\"matrx-print-block\">${output.html}</div>`;\n if (\"image\" in output) {\n const { src, alt, caption } = output.image;\n return `<figure class=\"matrx-print-block matrx-print-image\"><img src=\"${escapeHtml(src)}\" alt=\"${escapeHtml(\n alt ?? \"Picture\",\n )}\" style=\"max-width:100%;height:auto\">${caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : \"\"}</figure>`;\n }\n return `<p class=\"matrx-print-block matrx-print-notice\" style=\"border:1px dashed #94a3b8;border-radius:6px;padding:10px 12px;color:#475569;font-style:italic\">${escapeHtml(\n output.notice,\n )}</p>`;\n}\n\n// ─── Shared Utilities ─────────────────────────────────────────────────────────\n\nconst PRINT_STYLES = `\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;\n font-size: 11pt;\n line-height: 1.6;\n color: #1a1a1a;\n background: #fff;\n max-width: 860px;\n margin: 0 auto;\n padding: 28px 36px;\n }\n\n .print-actions {\n display: flex;\n gap: 10px;\n margin-bottom: 24px;\n padding-bottom: 18px;\n border-bottom: 2px solid #e5e7eb;\n }\n\n .print-btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 8px 20px;\n border-radius: 8px;\n font-size: 13px;\n font-family: inherit;\n cursor: pointer;\n border: none;\n font-weight: 600;\n }\n\n .print-btn-primary { background: #2563eb; color: #fff; }\n .print-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }\n\n h1 { font-size: 22pt; font-weight: 700; margin-bottom: 0.5em; }\n h2 { font-size: 16pt; font-weight: 700; margin: 1.2em 0 0.4em; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.2em; }\n h3 { font-size: 13pt; font-weight: 600; margin: 1em 0 0.3em; }\n p { margin-bottom: 0.7em; }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 1em 0;\n font-size: 10pt;\n }\n th {\n background: #1e293b;\n color: #f8fafc;\n font-weight: 600;\n text-align: left;\n padding: 7px 12px;\n border: 1px solid #334155;\n }\n td {\n padding: 6px 12px;\n border: 1px solid #e2e8f0;\n vertical-align: top;\n }\n tr:nth-child(even) td { background: #f8fafc; }\n\n @media print {\n body { max-width: 100%; padding: 0; }\n .print-actions { display: none !important; }\n th { -webkit-print-color-adjust: exact; print-color-adjust: exact; }\n }\n`;\n\n/**\n * Wrap body HTML in a complete, styled print document.\n */\nexport function buildPrintDocument(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(title)}</title>\n <style>\n${PRINT_STYLES}\n${extraStyles}\n </style>\n</head>\n<body>\n <div class=\"print-actions\">\n <button class=\"print-btn print-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"print-btn print-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n <div class=\"content\">\n${bodyHtml}\n </div>\n</body>\n</html>`;\n}\n\n/**\n * How a print request actually reached the user.\n *\n * \"opened\" — a print window/tab was opened and the document written into it.\n * \"downloaded\" — the popup was blocked, so the document was downloaded as an\n * .html file instead (the contract's fallback). Hosts SHOULD\n * surface this to the user (e.g. a toast saying the print file\n * was downloaded) — a blocked popup plus a silent download is\n * indistinguishable from \"nothing happened\", which is exactly\n * how QA filed it (2026-08-30, F5/F6/F8).\n */\nexport type PrintOutcome = \"opened\" | \"downloaded\";\n\n/**\n * Open a new browser window and write a complete HTML document into it.\n * Falls back to downloading an .html file if the popup is blocked.\n *\n * Returns which path was taken so callers can tell the user when the\n * fallback fired. `windowFeatures` customizes the popup geometry (the\n * flashcards fixed-geometry variants pass a wider window).\n */\n/**\n * Every printed document leaves through here, so this is where typeset math\n * gets its stylesheet: when the page carries KaTeX output and does not already\n * carry the sheet, it is added to <head>. A page with no math is untouched,\n * byte for byte (the flashcard golden master holds every printer to that).\n */\nexport function withMathStyles(htmlDocument: string): string {\n if (!htmlDocument.includes('class=\"katex\"') || htmlDocument.includes(KATEX_CSS_MARKER)) {\n return htmlDocument;\n }\n const style = `<style data-matrx-katex>${KATEX_CSS}</style>`;\n const head = htmlDocument.search(/<\\/head>/i);\n return head >= 0\n ? htmlDocument.slice(0, head) + style + htmlDocument.slice(head)\n : style + htmlDocument;\n}\n\nexport function openPrintWindow(\n rawDocument: string,\n filename = \"print\",\n windowFeatures = \"width=920,height=720,scrollbars=yes\",\n): PrintOutcome {\n const htmlDocument = withMathStyles(rawDocument);\n const win = openOutbound(\"\", \"_blank\", windowFeatures);\n // Popup blocked — fall back to file download.\n if (!win) return openPrintWindowFallback(htmlDocument, filename);\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n return \"opened\";\n}\n\n/** A print window opened before its document is ready (see `openPendingPrintWindow`). */\nexport interface PendingPrintWindow {\n /** Write the finished document; downloads it when the popup was blocked or closed. */\n write(htmlDocument: string): PrintOutcome;\n}\n\n/**\n * Open the print window NOW — inside the click, where popup blockers allow it —\n * and write the document later, once async preparation (drawing diagrams) is\n * done. The window says \"Preparing…\" meanwhile.\n */\nexport function openPendingPrintWindow(\n filename = \"print\",\n windowFeatures = \"width=920,height=720,scrollbars=yes\",\n): PendingPrintWindow {\n const win = openOutbound(\"\", \"_blank\", windowFeatures);\n if (win) {\n win.document.open();\n win.document.write(\n '<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><title>Preparing…</title></head><body style=\"font:14px -apple-system,BlinkMacSystemFont,sans-serif;color:#555;padding:2rem\">Preparing the document for print…</body></html>',\n );\n win.document.close();\n }\n return {\n write(rawDocument: string): PrintOutcome {\n const htmlDocument = withMathStyles(rawDocument);\n if (!win || win.closed) return openPrintWindowFallback(htmlDocument, filename);\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n return \"opened\";\n },\n };\n}\n\nfunction openPrintWindowFallback(htmlDocument: string, filename: string): PrintOutcome {\n downloadFile(`${filename.replace(/\\s+/g, \"-\").toLowerCase()}.html`, htmlDocument, \"text/html;charset=utf-8\");\n return \"downloaded\";\n}\n\n/**\n * Shorthand: build the document and open it in one call.\n * Returns the same PrintOutcome as openPrintWindow.\n */\nexport function printHtmlContent(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): PrintOutcome {\n const doc = buildPrintDocument(bodyHtml, title, extraStyles);\n return openPrintWindow(doc, title);\n}\n\n/**\n * Build the auto-shrink script that runs INSIDE a printed window: for every\n * element matching each selector, its font-size is reduced (0.5pt steps, 6pt\n * floor) until it fits its parent element's box. This is the flashcards\n * FIT_TEXT behavior verbatim, parameterized by selector so every lane\n * (flashcards card text, Avery text, label captions) shares the ONE loop.\n *\n * Pass the same selectors the original used to get byte-identical behavior:\n * flashcards passes `[\".card-text .card-text-inner\", \".avery-text-wrap .avery-text\"]`.\n */\nexport function buildFitTextScript(selectors: readonly string[]): string {\n const selectorsJson = JSON.stringify([...selectors]);\n return `\n<script>\n(function() {\n function fitText(el, container) {\n // container is the bounding box we must fit inside\n var maxW = container.clientWidth;\n var maxH = container.clientHeight;\n var size = parseFloat(window.getComputedStyle(el).fontSize);\n var minSize = 6;\n // Give the element a chance to reflow at current size before measuring\n el.style.fontSize = size + \"pt\";\n while (size > minSize && (el.scrollWidth > maxW + 1 || el.scrollHeight > maxH + 1)) {\n size -= 0.5;\n el.style.fontSize = size + \"pt\";\n }\n }\n function runAll() {\n var selectors = ${selectorsJson};\n for (var s = 0; s < selectors.length; s++) {\n var els = document.querySelectorAll(selectors[s]);\n for (var i = 0; i < els.length; i++) {\n fitText(els[i], els[i].parentElement);\n }\n }\n }\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", runAll);\n } else {\n // document.write pages may already be loaded\n setTimeout(runAll, 0);\n }\n})();\n</script>`;\n}\n\n\n// ─── DOM print: a rendered body, as it looks ─────────────────────────────────\n\nexport interface PrintElementOptions {\n /** The print window's title (and the download fallback's file name). */\n title?: string;\n /** Open the system print dialog once the window has loaded. Default true. */\n autoPrint?: boolean;\n /** Keep buttons, toolbars and menus in the print (default false: they are chrome, not content). */\n keepInteractive?: boolean;\n}\n\n/**\n * Interactive chrome that is not content: anything marked `data-print-hide`,\n * toolbars and menus, and buttons (\"Chart this\", \"Save to\", icon actions).\n * A button stays when it IS content — marked `data-print-keep`, or a disclosure\n * trigger (`aria-expanded`) whose label is a heading — and so does anything\n * inside a `data-print-keep` region. Removal runs on the print clone only.\n */\nconst PRINT_CHROME_SELECTOR = [\n \"[data-print-hide]\",\n '[role=\"toolbar\"]',\n '[role=\"menu\"]',\n '[role=\"menubar\"]',\n \"button\",\n '[role=\"button\"]',\n 'input[type=\"button\"]',\n 'input[type=\"submit\"]',\n 'input[type=\"reset\"]',\n].join(\",\");\n\nexport function stripInteractiveChrome(root: ParentNode): number {\n let removed = 0;\n root.querySelectorAll(PRINT_CHROME_SELECTOR).forEach((node) => {\n if (!root.contains(node)) return; // already gone with a removed ancestor\n const explicit = node.hasAttribute(\"data-print-hide\");\n if (!explicit) {\n if (node.closest(\"[data-print-keep]\")) return;\n if (node.matches(\"button, [role=\\\"button\\\"]\") && node.hasAttribute(\"aria-expanded\")) return;\n }\n node.remove();\n removed += 1;\n });\n return removed;\n}\n\nconst ELEMENT_PRINT_CSS = `\n html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; background: #fff !important; }\n body { margin: 0; padding: 16px; }\n *, *::before, *::after { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }\n .matrx-print-root, .matrx-print-root * { max-height: none !important; }\n .matrx-print-root [class*=\"overflow-\"], .matrx-print-root [class*=\"h-full\"], .matrx-print-root [class*=\"min-h-0\"] { overflow: visible !important; height: auto !important; }\n .matrx-print-frame-notice { border: 1px dashed #94a3b8; border-radius: 6px; padding: 10px 12px; color: #475569; font: italic 12px sans-serif; }\n @media print { .matrx-print-root * { break-inside: auto; } img, svg, figure, tr { break-inside: avoid; } }\n`;\n\n/**\n * Prints a same-origin DOM body exactly as it is drawn: the element's markup\n * with the page's own stylesheets, light theme, backgrounds kept, scroll\n * containers opened to full height. A `<canvas>` prints as its picture; an\n * `<iframe>` cannot be copied and prints an honest notice in its place (a\n * frame kind declares its own print instead). Opens inside the click.\n */\nexport function printElement(element: HTMLElement, options: PrintElementOptions = {}): PrintOutcome {\n const title = options.title ?? (document.title || \"Print\");\n const head = [...document.querySelectorAll<HTMLLinkElement | HTMLStyleElement>('link[rel=\"stylesheet\"], style')]\n .map((node) => {\n if (node instanceof HTMLLinkElement) return `<link rel=\"stylesheet\" href=\"${escapeHtml(node.href)}\">`;\n return `<style>${node.textContent ?? \"\"}</style>`;\n })\n .join(\"\\n\");\n const clone = element.cloneNode(true) as HTMLElement;\n const liveCanvases = element.querySelectorAll(\"canvas\");\n clone.querySelectorAll(\"canvas\").forEach((canvas, i) => {\n const live = liveCanvases[i];\n const img = document.createElement(\"img\");\n try {\n img.src = live ? live.toDataURL(\"image/png\") : \"\";\n } catch {\n img.alt = \"This drawing cannot be copied for print.\";\n }\n img.style.maxWidth = \"100%\";\n canvas.replaceWith(img);\n });\n clone.querySelectorAll(\"iframe\").forEach((frame) => {\n const note = document.createElement(\"p\");\n note.className = \"matrx-print-frame-notice\";\n note.textContent = \"An embedded page is here — print it from its own Print.\";\n frame.replaceWith(note);\n });\n clone.querySelectorAll(\"script\").forEach((node) => node.remove());\n if (options.keepInteractive !== true) stripInteractiveChrome(clone);\n const htmlClass = document.documentElement.className.replace(/\\bdark\\b/g, \"light\");\n const autoPrint = options.autoPrint ?? true;\n const doc = `<!DOCTYPE html><html lang=\"en\" class=\"${escapeHtml(htmlClass)}\"><head><meta charset=\"UTF-8\"><base href=\"${escapeHtml(\n document.baseURI,\n )}\"><title>${escapeHtml(title)}</title>${head}<style>${ELEMENT_PRINT_CSS}</style></head><body><div class=\"matrx-print-root\">${\n clone.outerHTML\n }</div>${\n autoPrint\n ? '<script>window.addEventListener(\"load\",function(){setTimeout(function(){window.focus();window.print();},250);});</script>'\n : \"\"\n }</body></html>`;\n return openPrintWindow(doc, title);\n}\n","// GENERATED by scripts/gen-katex-css.mjs from katex@0.18.9 — do not edit.\nexport const KATEX_VERSION = \"0.18.9\";\n/** Leads KATEX_CSS; lets openPrintWindow see the sheet is already present. */\nexport const KATEX_CSS_MARKER = \"/* matrx-katex@0.18.9 */\";\nexport const KATEX_CSS = \"/* matrx-katex@0.18.9 */@font-face{font-display:block;font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_AMS-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_AMS-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_AMS-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Bold.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Bold.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Bold.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Bold.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Bold.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Bold.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Bold.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Bold.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Bold.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-BoldItalic.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-BoldItalic.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-BoldItalic.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Italic.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Italic.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Italic.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-BoldItalic.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-BoldItalic.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-BoldItalic.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-Italic.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-Italic.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-Italic.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:\\\"KaTeX_SansSerif\\\";font-style:normal;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Bold.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Bold.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Bold.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:\\\"KaTeX_SansSerif\\\";font-style:italic;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Italic.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Italic.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Italic.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:\\\"KaTeX_SansSerif\\\";font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Script-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Script-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Script-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size1-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size1-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size1-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size2-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size2-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size2-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size3-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size3-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size3-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size4-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size4-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size4-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Typewriter-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Typewriter-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Typewriter-Regular.ttf) format(\\\"truetype\\\")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;position:relative;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:\\\"0.18.9\\\"}.katex .katex-mathml{border:0;-webkit-clip-path:inset(50%);clip-path:inset(50%);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.katex .katex-html>.katex-newline{display:block}.katex .katex-base{position:relative;white-space:nowrap;width:-webkit-min-content;width:-moz-min-content;width:min-content}.katex .katex-base,.katex .katex-strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-style:italic;font-weight:700}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathboldfrak,.katex .textboldfrak{font-family:KaTeX_Fraktur;font-weight:700}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .mathsfit,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{border-collapse:collapse;display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;position:relative;vertical-align:bottom}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;font-size:1px;min-width:2px;vertical-align:bottom;width:2px}.katex .katex-vbox{align-items:baseline;display:inline-flex;flex-direction:column}.katex .katex-thinbox{display:inline-flex;flex-direction:row;max-width:0;width:0}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .katex-hdashline,.katex .katex-hline,.katex .katex-overline .overline-line,.katex .katex-rule,.katex .katex-underline .underline-line,.katex .mfrac .frac-line{min-height:1px}.katex .mspace{display:inline-block}.katex .katex-smash{display:inline;line-height:0}.katex .clap,.katex .llap,.katex .rlap{position:relative;width:0}.katex .clap>.katex-inner,.katex .llap>.katex-inner,.katex .rlap>.katex-inner{position:absolute}.katex .clap>.katex-fix,.katex .llap>.katex-fix,.katex .rlap>.katex-fix{display:inline-block}.katex .llap>.katex-inner{right:0}.katex .clap>.katex-inner,.katex .rlap>.katex-inner{left:0}.katex .clap>.katex-inner>span{margin-left:-50%;margin-right:50%}.katex .katex-rule{border:0 solid;display:inline-block;position:relative}.katex .katex-hline,.katex .katex-overline .overline-line,.katex .katex-underline .underline-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .katex-hdashline{border-bottom-style:dashed;display:inline-block;width:100%}.katex .sqrt>.katex-root{margin-left:.2777777778em;margin-right:-.5555555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .katex-sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .katex-sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .katex-sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .katex-sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .katex-sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .katex-sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .katex-sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .katex-sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .katex-sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .katex-sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .katex-sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .katex-sizing.reset-size2.size1{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .katex-sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .katex-sizing.reset-size2.size3{font-size:1.1666666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .katex-sizing.reset-size2.size4{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .katex-sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .katex-sizing.reset-size2.size6{font-size:1.6666666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .katex-sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .katex-sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .katex-sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .katex-sizing.reset-size2.size10{font-size:3.4566666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .katex-sizing.reset-size2.size11{font-size:4.1466666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .katex-sizing.reset-size3.size1{font-size:.7142857143em}.katex .fontsize-ensurer.reset-size3.size2,.katex .katex-sizing.reset-size3.size2{font-size:.8571428571em}.katex .fontsize-ensurer.reset-size3.size3,.katex .katex-sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .katex-sizing.reset-size3.size4{font-size:1.1428571429em}.katex .fontsize-ensurer.reset-size3.size5,.katex .katex-sizing.reset-size3.size5{font-size:1.2857142857em}.katex .fontsize-ensurer.reset-size3.size6,.katex .katex-sizing.reset-size3.size6{font-size:1.4285714286em}.katex .fontsize-ensurer.reset-size3.size7,.katex .katex-sizing.reset-size3.size7{font-size:1.7142857143em}.katex .fontsize-ensurer.reset-size3.size8,.katex .katex-sizing.reset-size3.size8{font-size:2.0571428571em}.katex .fontsize-ensurer.reset-size3.size9,.katex .katex-sizing.reset-size3.size9{font-size:2.4685714286em}.katex .fontsize-ensurer.reset-size3.size10,.katex .katex-sizing.reset-size3.size10{font-size:2.9628571429em}.katex .fontsize-ensurer.reset-size3.size11,.katex .katex-sizing.reset-size3.size11{font-size:3.5542857143em}.katex .fontsize-ensurer.reset-size4.size1,.katex .katex-sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .katex-sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .katex-sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .katex-sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .katex-sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .katex-sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .katex-sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .katex-sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .katex-sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .katex-sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .katex-sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .katex-sizing.reset-size5.size1{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .katex-sizing.reset-size5.size2{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .katex-sizing.reset-size5.size3{font-size:.7777777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .katex-sizing.reset-size5.size4{font-size:.8888888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .katex-sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .katex-sizing.reset-size5.size6{font-size:1.1111111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .katex-sizing.reset-size5.size7{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .katex-sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .katex-sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .katex-sizing.reset-size5.size10{font-size:2.3044444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .katex-sizing.reset-size5.size11{font-size:2.7644444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .katex-sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .katex-sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .katex-sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .katex-sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .katex-sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .katex-sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .katex-sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .katex-sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .katex-sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .katex-sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .katex-sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .katex-sizing.reset-size7.size1{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .katex-sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .katex-sizing.reset-size7.size3{font-size:.5833333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .katex-sizing.reset-size7.size4{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .katex-sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .katex-sizing.reset-size7.size6{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .katex-sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .katex-sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .katex-sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .katex-sizing.reset-size7.size10{font-size:1.7283333333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .katex-sizing.reset-size7.size11{font-size:2.0733333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .katex-sizing.reset-size8.size1{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .katex-sizing.reset-size8.size2{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .katex-sizing.reset-size8.size3{font-size:.4861111111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .katex-sizing.reset-size8.size4{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .katex-sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .katex-sizing.reset-size8.size6{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .katex-sizing.reset-size8.size7{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .katex-sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .katex-sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .katex-sizing.reset-size8.size10{font-size:1.4402777778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .katex-sizing.reset-size8.size11{font-size:1.7277777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .katex-sizing.reset-size9.size1{font-size:.2893518519em}.katex .fontsize-ensurer.reset-size9.size2,.katex .katex-sizing.reset-size9.size2{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .katex-sizing.reset-size9.size3{font-size:.4050925926em}.katex .fontsize-ensurer.reset-size9.size4,.katex .katex-sizing.reset-size9.size4{font-size:.462962963em}.katex .fontsize-ensurer.reset-size9.size5,.katex .katex-sizing.reset-size9.size5{font-size:.5208333333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .katex-sizing.reset-size9.size6{font-size:.5787037037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .katex-sizing.reset-size9.size7{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .katex-sizing.reset-size9.size8{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .katex-sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .katex-sizing.reset-size9.size10{font-size:1.2002314815em}.katex .fontsize-ensurer.reset-size9.size11,.katex .katex-sizing.reset-size9.size11{font-size:1.4398148148em}.katex .fontsize-ensurer.reset-size10.size1,.katex .katex-sizing.reset-size10.size1{font-size:.2410800386em}.katex .fontsize-ensurer.reset-size10.size2,.katex .katex-sizing.reset-size10.size2{font-size:.2892960463em}.katex .fontsize-ensurer.reset-size10.size3,.katex .katex-sizing.reset-size10.size3{font-size:.337512054em}.katex .fontsize-ensurer.reset-size10.size4,.katex .katex-sizing.reset-size10.size4{font-size:.3857280617em}.katex .fontsize-ensurer.reset-size10.size5,.katex .katex-sizing.reset-size10.size5{font-size:.4339440694em}.katex .fontsize-ensurer.reset-size10.size6,.katex .katex-sizing.reset-size10.size6{font-size:.4821600771em}.katex .fontsize-ensurer.reset-size10.size7,.katex .katex-sizing.reset-size10.size7{font-size:.5785920926em}.katex .fontsize-ensurer.reset-size10.size8,.katex .katex-sizing.reset-size10.size8{font-size:.6943105111em}.katex .fontsize-ensurer.reset-size10.size9,.katex .katex-sizing.reset-size10.size9{font-size:.8331726133em}.katex .fontsize-ensurer.reset-size10.size10,.katex .katex-sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .katex-sizing.reset-size10.size11{font-size:1.1996142719em}.katex .fontsize-ensurer.reset-size11.size1,.katex .katex-sizing.reset-size11.size1{font-size:.2009646302em}.katex .fontsize-ensurer.reset-size11.size2,.katex .katex-sizing.reset-size11.size2{font-size:.2411575563em}.katex .fontsize-ensurer.reset-size11.size3,.katex .katex-sizing.reset-size11.size3{font-size:.2813504823em}.katex .fontsize-ensurer.reset-size11.size4,.katex .katex-sizing.reset-size11.size4{font-size:.3215434084em}.katex .fontsize-ensurer.reset-size11.size5,.katex .katex-sizing.reset-size11.size5{font-size:.3617363344em}.katex .fontsize-ensurer.reset-size11.size6,.katex .katex-sizing.reset-size11.size6{font-size:.4019292605em}.katex .fontsize-ensurer.reset-size11.size7,.katex .katex-sizing.reset-size11.size7{font-size:.4823151125em}.katex .fontsize-ensurer.reset-size11.size8,.katex .katex-sizing.reset-size11.size8{font-size:.578778135em}.katex .fontsize-ensurer.reset-size11.size9,.katex .katex-sizing.reset-size11.size9{font-size:.6945337621em}.katex .fontsize-ensurer.reset-size11.size10,.katex .katex-sizing.reset-size11.size10{font-size:.8336012862em}.katex .fontsize-ensurer.reset-size11.size11,.katex .katex-sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .katex-accent>.vlist-t,.katex .op-limits>.vlist-t{text-align:center}.katex .katex-accent .accent-body{position:relative}.katex .katex-accent .accent-body:not(.accent-full){width:0}.katex .katex-overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{fill:currentColor;stroke:currentColor;display:block;height:inherit;position:absolute;width:100%}.katex svg path{stroke:none}.katex svg{fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1}.katex img{border-style:none;max-height:none;max-width:none;min-height:0;min-width:0}.katex .katex-stretchy{display:block;overflow:hidden;position:relative;width:100%}.katex .katex-stretchy:after,.katex .katex-stretchy:before{content:\\\"\\\"}.katex .hide-tail{overflow:hidden;position:relative;width:100%}.katex .halfarrow-left{left:0;overflow:hidden;position:absolute;width:50.2%}.katex .halfarrow-right{overflow:hidden;position:absolute;right:0;width:50.2%}.katex .brace-left{left:0;overflow:hidden;position:absolute;width:25.1%}.katex .brace-center{left:25%;overflow:hidden;position:absolute;width:50%}.katex .brace-right{overflow:hidden;position:absolute;right:0;width:25.1%}.katex .x-arrow-pad{padding:0 .5em}.katex .cd-arrow-pad{padding:0 .55556em 0 .27778em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{border:.04em solid;box-sizing:border-box}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .katex-sout{border-bottom-style:solid;border-bottom-width:.08em}.katex .angl{border-right:.049em solid;border-top:.049em solid;box-sizing:border-box;margin-right:.03889em}.katex .anglpad{padding:0 .03889em}.katex .reflectbox{display:inline-block;transform:scaleX(-1)}.katex .eqn-num:before{content:\\\"(\\\" counter(katexEqnNo) \\\")\\\";counter-increment:katexEqnNo}.katex .mml-eqn-num:before{content:\\\"(\\\" counter(mmlEqnNo) \\\")\\\";counter-increment:mmlEqnNo}.katex .mtr-glue{width:50%}.katex .cd-vert-arrow{display:inline-block;position:relative}.katex .cd-label-left{display:inline-block;position:absolute;right:calc(50% + .3em);text-align:left}.katex .cd-label-right{display:inline-block;left:calc(50% + .3em);position:absolute;text-align:right}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.katex-tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.katex-tag{left:0;right:auto}.katex-display.fleqn>.katex{padding-left:2em;text-align:left}body{counter-reset:katexEqnNo mmlEqnNo}\\n\";\n","/**\n * @ai-matrx/print/certificate — the certificate / acknowledgment page, and the\n * workbook composer that ends on one.\n *\n * ── ONE PRIMITIVE, TWO VERTICALS ────────────────────────────────────────────\n * PRINT-EDUCATION-RESEARCH.md §\"What the future-builders do\", finding 6:\n * the HR-handbook world (Blissbook / AirMason) builds the SAME primitives as\n * the test-prep world — cover → TOC → sections → signature page, where the\n * HR *acknowledgment/signature page* and the course *certificate page* are the\n * SAME printable unit wearing different words. So they are ONE printer here:\n * `formal` and `modern-minimal` are the education/course faces, and\n * `acknowledgment` is the HR twin (portrait, body text, \"I acknowledge…\",\n * signature and date rules). Build once, share across verticals — that is the\n * finding, implemented.\n *\n * Ornamentation is PURE CSS (nested/double borders + corner flourishes built\n * from border tricks). No images, no fonts to load, no external anything: a\n * certificate must print identically from a print window with no network.\n *\n * Import-inert: `window` is touched only inside `print`/`printWorkbook`.\n * `composeWorkbookPages` is pure and safe to call on a server.\n */\n\nimport { openPrintWindow, type BlockPrinter, type PrintOutcome, type PrintSettings } from \"./core\";\nimport { escapeHtml } from \"@ai-matrx/kit/html-escape\";\nimport { printBooklet } from \"./booklet\";\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface SignatureLine {\n /** Printed under the rule, above the role. Omitted = a blank rule to sign. */\n name?: string;\n role: string;\n}\n\nexport interface CertificateData {\n recipientName: string;\n title: string;\n subtitle?: string;\n body?: string;\n date?: string;\n signatures?: SignatureLine[];\n}\n\nexport type CertificateVariant = \"formal\" | \"modern-minimal\" | \"acknowledgment\";\n\n// ─── Data extraction ─────────────────────────────────────────────────────────\n\nfunction asString(value: unknown): string | undefined {\n return typeof value === \"string\" && value.trim().length > 0\n ? value.trim()\n : undefined;\n}\n\nfunction parseSignature(raw: unknown): SignatureLine | null {\n if (!raw || typeof raw !== \"object\") return null;\n const obj = raw as Record<string, unknown>;\n const role = asString(obj[\"role\"]);\n if (role === undefined) return null;\n const name = asString(obj[\"name\"]);\n return name === undefined ? { role } : { name, role };\n}\n\n/** Tolerant reader for host data — always yields a printable certificate. */\nexport function extractCertificate(data: unknown): CertificateData {\n if (!data || typeof data !== \"object\") {\n return { recipientName: \"\", title: \"Certificate\" };\n }\n const obj = data as Record<string, unknown>;\n const cert: CertificateData = {\n recipientName: asString(obj[\"recipientName\"]) ?? \"\",\n title: asString(obj[\"title\"]) ?? \"Certificate\",\n };\n const subtitle = asString(obj[\"subtitle\"]);\n if (subtitle !== undefined) cert.subtitle = subtitle;\n const body = asString(obj[\"body\"]);\n if (body !== undefined) cert.body = body;\n const date = asString(obj[\"date\"]);\n if (date !== undefined) cert.date = date;\n const rawSignatures = obj[\"signatures\"];\n if (Array.isArray(rawSignatures)) {\n const signatures = rawSignatures\n .map(parseSignature)\n .filter((s): s is SignatureLine => s !== null);\n if (signatures.length > 0) cert.signatures = signatures;\n }\n return cert;\n}\n\n// ─── Styles ──────────────────────────────────────────────────────────────────\n\nconst CERT_RESET = `\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n html, body {\n background: #fff;\n color: #111827;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;\n }\n .screen-only { padding: 14px 20px; border-bottom: 1px solid #e2e8f0; display: flex; gap: 10px; }\n .cert-btn {\n padding: 8px 20px; border-radius: 8px; font-size: 13px; font-family: inherit;\n font-weight: 600; cursor: pointer; border: none;\n }\n .cert-btn-primary { background: #2563eb; color: #fff; }\n .cert-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }\n @media print { .screen-only { display: none !important; } }\n\n .cert-signatures {\n display: flex;\n justify-content: center;\n gap: 0.8in;\n margin-top: 0.5in;\n }\n .cert-sig { min-width: 2.2in; text-align: center; }\n .cert-sig-rule {\n border-bottom: 1.5px solid #111827;\n height: 0.42in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-sig-name { font-size: 11pt; font-weight: 600; margin-top: 4px; }\n .cert-sig-role {\n font-size: 8.5pt;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: #4b5563;\n margin-top: 2px;\n }\n`;\n\n/**\n * The ornamental frame: a double border (outer heavy rule + inner hairline)\n * plus four corner flourishes drawn from two borders each — a pure-CSS\n * engraving that never depends on an image loading.\n */\nconst FORMAL_STYLES = `\n ${CERT_RESET}\n @page { size: 11in 8.5in; margin: 0; }\n\n .cert-page {\n width: 11in;\n height: 8.5in;\n padding: 0.45in;\n position: relative;\n break-after: page;\n page-break-after: always;\n }\n .cert-page:last-of-type { break-after: auto; page-break-after: auto; }\n\n .cert-frame {\n position: relative;\n width: 100%;\n height: 100%;\n border: 6px double #7c5c1e;\n padding: 0.28in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-frame-inner {\n width: 100%;\n height: 100%;\n border: 1px solid #b08b3f;\n padding: 0.5in 0.7in;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n text-align: center;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n /* Corner flourishes — two borders per pseudo-element, rotated by placement. */\n .cert-corner {\n position: absolute;\n width: 0.55in;\n height: 0.55in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-corner-tl { top: 0.1in; left: 0.1in; border-top: 3px solid #b08b3f; border-left: 3px solid #b08b3f; }\n .cert-corner-tr { top: 0.1in; right: 0.1in; border-top: 3px solid #b08b3f; border-right: 3px solid #b08b3f; }\n .cert-corner-bl { bottom: 0.1in; left: 0.1in; border-bottom: 3px solid #b08b3f; border-left: 3px solid #b08b3f; }\n .cert-corner-br { bottom: 0.1in; right: 0.1in; border-bottom: 3px solid #b08b3f; border-right: 3px solid #b08b3f; }\n\n .cert-eyebrow {\n font-size: 11pt;\n letter-spacing: 0.34em;\n text-transform: uppercase;\n color: #7c5c1e;\n }\n .cert-title { font-size: 30pt; font-weight: 700; margin-top: 0.1in; letter-spacing: 0.02em; }\n .cert-subtitle { font-size: 12pt; color: #4b5563; margin-top: 0.08in; }\n .cert-presented { font-size: 10.5pt; color: #4b5563; margin-top: 0.32in; letter-spacing: 0.16em; text-transform: uppercase; }\n .cert-recipient {\n font-size: 34pt;\n font-weight: 400;\n font-family: 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif;\n margin-top: 0.06in;\n padding-bottom: 0.08in;\n border-bottom: 1px solid #b08b3f;\n min-width: 5in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-body { font-size: 11pt; line-height: 1.6; max-width: 6.6in; margin-top: 0.24in; color: #1f2937; }\n .cert-date { font-size: 10pt; color: #4b5563; margin-top: 0.24in; }\n`;\n\nconst MODERN_STYLES = `\n ${CERT_RESET}\n @page { size: 11in 8.5in; margin: 0; }\n\n .cert-page {\n width: 11in;\n height: 8.5in;\n padding: 0.9in 1.1in;\n display: flex;\n flex-direction: column;\n justify-content: center;\n break-after: page;\n page-break-after: always;\n }\n .cert-page:last-of-type { break-after: auto; page-break-after: auto; }\n\n .cert-rule-top {\n width: 1.6in;\n border-top: 4px solid #111827;\n margin-bottom: 0.3in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-eyebrow {\n font-size: 9.5pt;\n letter-spacing: 0.3em;\n text-transform: uppercase;\n color: #6b7280;\n }\n .cert-title { font-size: 22pt; font-weight: 600; margin-top: 0.06in; }\n .cert-subtitle { font-size: 11pt; color: #6b7280; margin-top: 0.05in; }\n .cert-recipient { font-size: 40pt; font-weight: 700; letter-spacing: -0.01em; margin-top: 0.3in; }\n .cert-body { font-size: 11pt; line-height: 1.65; max-width: 6.2in; margin-top: 0.2in; color: #374151; }\n .cert-date { font-size: 10pt; color: #6b7280; margin-top: 0.24in; }\n .cert-signatures { justify-content: flex-start; gap: 0.7in; }\n .cert-sig { text-align: left; }\n`;\n\nconst ACK_STYLES = `\n ${CERT_RESET}\n @page { size: 8.5in 11in; margin: 0.75in; }\n\n .cert-page {\n break-after: page;\n page-break-after: always;\n }\n .cert-page:last-of-type { break-after: auto; page-break-after: auto; }\n\n .cert-title { font-size: 17pt; font-weight: 700; }\n .cert-subtitle { font-size: 11pt; color: #4b5563; margin-top: 3px; }\n .cert-body { font-size: 11pt; line-height: 1.7; margin-top: 0.28in; color: #1f2937; }\n .cert-ack-statement {\n margin-top: 0.3in;\n padding: 0.16in 0.2in;\n border: 1.5px solid #111827;\n font-size: 11pt;\n line-height: 1.65;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-ack-fields { margin-top: 0.4in; display: flex; gap: 0.4in; }\n .cert-ack-field { flex: 1; }\n .cert-ack-field-date { flex: 0 0 2in; }\n .cert-ack-rule {\n border-bottom: 1.5px solid #111827;\n height: 0.4in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-ack-label {\n font-size: 8.5pt;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: #4b5563;\n margin-top: 3px;\n }\n .cert-signatures { justify-content: flex-start; gap: 0.6in; }\n .cert-sig { text-align: left; }\n`;\n\n// ─── Rendering ───────────────────────────────────────────────────────────────\n\nfunction renderSignatures(signatures: readonly SignatureLine[]): string {\n if (signatures.length === 0) return \"\";\n const lines = signatures\n .map(\n (sig) => `<div class=\"cert-sig\">\n <div class=\"cert-sig-rule\"></div>\n ${sig.name ? `<div class=\"cert-sig-name\">${escapeHtml(sig.name)}</div>` : \"\"}\n <div class=\"cert-sig-role\">${escapeHtml(sig.role)}</div>\n </div>`,\n )\n .join(\"\");\n return `<div class=\"cert-signatures\">${lines}</div>`;\n}\n\nfunction renderFormal(cert: CertificateData): string {\n return `<div class=\"cert-page cert-formal\">\n <div class=\"cert-frame\">\n <span class=\"cert-corner cert-corner-tl\"></span>\n <span class=\"cert-corner cert-corner-tr\"></span>\n <span class=\"cert-corner cert-corner-bl\"></span>\n <span class=\"cert-corner cert-corner-br\"></span>\n <div class=\"cert-frame-inner\">\n <div class=\"cert-eyebrow\">Certificate</div>\n <div class=\"cert-title\">${escapeHtml(cert.title)}</div>\n ${cert.subtitle ? `<div class=\"cert-subtitle\">${escapeHtml(cert.subtitle)}</div>` : \"\"}\n <div class=\"cert-presented\">This certifies that</div>\n <div class=\"cert-recipient\">${escapeHtml(cert.recipientName)}</div>\n ${cert.body ? `<div class=\"cert-body\">${escapeHtml(cert.body)}</div>` : \"\"}\n ${cert.date ? `<div class=\"cert-date\">${escapeHtml(cert.date)}</div>` : \"\"}\n ${renderSignatures(cert.signatures ?? [])}\n </div>\n </div>\n</div>`;\n}\n\nfunction renderModernMinimal(cert: CertificateData): string {\n return `<div class=\"cert-page cert-modern\">\n <div class=\"cert-rule-top\"></div>\n <div class=\"cert-eyebrow\">Certificate of completion</div>\n <div class=\"cert-title\">${escapeHtml(cert.title)}</div>\n ${cert.subtitle ? `<div class=\"cert-subtitle\">${escapeHtml(cert.subtitle)}</div>` : \"\"}\n <div class=\"cert-recipient\">${escapeHtml(cert.recipientName)}</div>\n ${cert.body ? `<div class=\"cert-body\">${escapeHtml(cert.body)}</div>` : \"\"}\n ${cert.date ? `<div class=\"cert-date\">${escapeHtml(cert.date)}</div>` : \"\"}\n ${renderSignatures(cert.signatures ?? [])}\n</div>`;\n}\n\n/** The default acknowledgment sentence when the data supplies no body text. */\nconst ACK_STATEMENT =\n \"I acknowledge that I have received, read and understand this document, and I agree to comply with its contents.\";\n\nfunction renderAcknowledgment(cert: CertificateData): string {\n return `<div class=\"cert-page cert-acknowledgment\">\n <div class=\"cert-title\">${escapeHtml(cert.title)}</div>\n ${cert.subtitle ? `<div class=\"cert-subtitle\">${escapeHtml(cert.subtitle)}</div>` : \"\"}\n ${cert.body ? `<div class=\"cert-body\">${escapeHtml(cert.body)}</div>` : \"\"}\n <div class=\"cert-ack-statement\">${escapeHtml(ACK_STATEMENT)}</div>\n <div class=\"cert-ack-fields\">\n <div class=\"cert-ack-field\">\n <div class=\"cert-ack-rule\"></div>\n <div class=\"cert-ack-label\">Printed name${cert.recipientName ? ` — ${escapeHtml(cert.recipientName)}` : \"\"}</div>\n </div>\n <div class=\"cert-ack-field\">\n <div class=\"cert-ack-rule\"></div>\n <div class=\"cert-ack-label\">Signature</div>\n </div>\n <div class=\"cert-ack-field cert-ack-field-date\">\n <div class=\"cert-ack-rule\"></div>\n <div class=\"cert-ack-label\">Date${cert.date ? ` — ${escapeHtml(cert.date)}` : \"\"}</div>\n </div>\n </div>\n ${renderSignatures(cert.signatures ?? [])}\n</div>`;\n}\n\nfunction stylesFor(variant: CertificateVariant): string {\n switch (variant) {\n case \"modern-minimal\":\n return MODERN_STYLES;\n case \"acknowledgment\":\n return ACK_STYLES;\n case \"formal\":\n default:\n return FORMAL_STYLES;\n }\n}\n\n/** Render one certificate page fragment — the unit the workbook composer reuses. */\nexport function renderCertificatePage(\n cert: CertificateData,\n variant: CertificateVariant = \"formal\",\n): string {\n switch (variant) {\n case \"modern-minimal\":\n return renderModernMinimal(cert);\n case \"acknowledgment\":\n return renderAcknowledgment(cert);\n case \"formal\":\n default:\n return renderFormal(cert);\n }\n}\n\nfunction buildCertificateDocument(\n cert: CertificateData,\n variant: CertificateVariant,\n): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <title>${escapeHtml(cert.title)}</title>\n <style>\n${stylesFor(variant)}\n </style>\n</head>\n<body>\n <div class=\"screen-only\">\n <button class=\"cert-btn cert-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"cert-btn cert-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n${renderCertificatePage(cert, variant)}\n</body>\n</html>`;\n}\n\n/**\n * The certificate printer — and, in its `acknowledgment` variant, the HR\n * signature/acknowledgment page. They are ONE primitive on purpose\n * (PRINT-EDUCATION-RESEARCH.md §\"What the future-builders do\", finding 6:\n * the HR-handbook builders assemble cover → TOC → sections → signature page,\n * which is the same spine as a course workbook ending in a certificate).\n * Ornamentation is pure CSS; nothing here loads an image or a webfont.\n */\nexport const certificatePrinter: BlockPrinter = {\n label: \"Print certificate\",\n variants: [\n {\n id: \"formal\",\n label: \"Formal (ornamental border)\",\n description:\n \"Landscape letter with a double gold rule and corner flourishes — the classic course/award certificate. Pure CSS, no images\",\n },\n {\n id: \"modern-minimal\",\n label: \"Modern minimal\",\n description:\n \"Landscape letter, left-aligned, no ornament — a large recipient name over a single rule\",\n },\n {\n id: \"acknowledgment\",\n label: \"Acknowledgment / signature page (HR)\",\n description:\n \"Portrait letter: policy body text, an \\\"I acknowledge…\\\" statement, and printed-name / signature / date rules. The HR twin of the certificate page\",\n },\n ],\n\n print(\n data: unknown,\n variantId: string = \"formal\",\n _settings?: PrintSettings,\n ): PrintOutcome {\n const cert = extractCertificate(data);\n const variant = (\n [\"formal\", \"modern-minimal\", \"acknowledgment\"].includes(variantId)\n ? variantId\n : \"formal\"\n ) as CertificateVariant;\n return openPrintWindow(\n buildCertificateDocument(cert, variant),\n cert.title,\n \"width=1100,height=760,scrollbars=yes\",\n );\n },\n};\n\n// ─── Workbook composer ───────────────────────────────────────────────────────\n\nexport interface WorkbookSection {\n title: string;\n /** Already-rendered page fragments — TRUSTED HTML, injected verbatim. */\n pagesHtml: readonly string[];\n}\n\nexport interface WorkbookSpec {\n title: string;\n subtitle?: string;\n sections: readonly WorkbookSection[];\n includeToc?: boolean;\n finalPage?:\n | { kind: \"certificate\"; data: CertificateData }\n | { kind: \"html\"; html: string };\n}\n\nconst WORKBOOK_STYLES = `\n .wb-cover {\n height: 100%;\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n padding: 0.9in 0.7in;\n }\n .wb-cover-title { font-size: 26pt; font-weight: 700; line-height: 1.2; }\n .wb-cover-subtitle { font-size: 12pt; color: #4b5563; margin-top: 0.16in; }\n .wb-cover-rule {\n width: 1.4in;\n border-top: 3px solid #111827;\n margin: 0.28in 0;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .wb-toc { padding: 0.7in 0.7in; }\n .wb-toc h2 { font-size: 15pt; font-weight: 700; margin-bottom: 0.2in; }\n .wb-toc-row {\n display: flex;\n align-items: baseline;\n gap: 6px;\n font-size: 11pt;\n margin-bottom: 8px;\n }\n .wb-toc-label { white-space: nowrap; }\n .wb-toc-dots {\n flex: 1;\n border-bottom: 1px dotted #9ca3af;\n transform: translateY(-3px);\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .wb-toc-page { font-variant-numeric: tabular-nums; font-weight: 600; }\n .wb-section-title-page {\n height: 100%;\n display: flex;\n flex-direction: column;\n justify-content: center;\n padding: 0.9in 0.8in;\n }\n .wb-section-eyebrow {\n font-size: 9pt;\n letter-spacing: 0.28em;\n text-transform: uppercase;\n color: #6b7280;\n }\n .wb-section-name { font-size: 22pt; font-weight: 700; margin-top: 0.08in; }\n`;\n\n/** Page-break scaffolding for the SEQUENTIAL (non-booklet) workbook lane. */\nconst WORKBOOK_SEQUENTIAL_STYLES = `\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n @page { size: 8.5in 11in; margin: 0; }\n html, body {\n background: #fff;\n color: #111827;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;\n }\n .wb-page {\n width: 8.5in;\n height: 11in;\n overflow: hidden;\n position: relative;\n break-after: page;\n page-break-after: always;\n }\n .wb-page:last-of-type { break-after: auto; page-break-after: auto; }\n .screen-only { padding: 14px 20px; border-bottom: 1px solid #e2e8f0; display: flex; gap: 10px; }\n .wb-btn {\n padding: 8px 20px; border-radius: 8px; font-size: 13px; font-family: inherit;\n font-weight: 600; cursor: pointer; border: none;\n }\n .wb-btn-primary { background: #2563eb; color: #fff; }\n .wb-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }\n @media print { .screen-only { display: none !important; } }\n${WORKBOOK_STYLES}\n${CERT_RESET_FOR_WORKBOOK()}\n`;\n\n/**\n * The certificate's signature/frame rules, reused when a certificate rides\n * INSIDE a workbook. The certificate's own `@page` size is deliberately NOT\n * included — a workbook is portrait, and a landscape @page in the middle of it\n * would repaginate the whole document.\n */\nfunction CERT_RESET_FOR_WORKBOOK(): string {\n return `\n .cert-page { width: 100%; height: 100%; padding: 0.5in; break-after: auto; page-break-after: auto; }\n ${CERT_RESET.split(\"@media print\")[0] ?? \"\"}\n .cert-frame { position: relative; width: 100%; height: 100%; border: 6px double #7c5c1e; padding: 0.24in; -webkit-print-color-adjust: exact; print-color-adjust: exact; }\n .cert-frame-inner { width: 100%; height: 100%; border: 1px solid #b08b3f; padding: 0.4in; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }\n .cert-corner { position: absolute; width: 0.5in; height: 0.5in; }\n .cert-corner-tl { top: 0.1in; left: 0.1in; border-top: 3px solid #b08b3f; border-left: 3px solid #b08b3f; }\n .cert-corner-tr { top: 0.1in; right: 0.1in; border-top: 3px solid #b08b3f; border-right: 3px solid #b08b3f; }\n .cert-corner-bl { bottom: 0.1in; left: 0.1in; border-bottom: 3px solid #b08b3f; border-left: 3px solid #b08b3f; }\n .cert-corner-br { bottom: 0.1in; right: 0.1in; border-bottom: 3px solid #b08b3f; border-right: 3px solid #b08b3f; }\n .cert-eyebrow { font-size: 10pt; letter-spacing: 0.3em; text-transform: uppercase; color: #7c5c1e; }\n .cert-title { font-size: 22pt; font-weight: 700; margin-top: 0.08in; }\n .cert-subtitle { font-size: 11pt; color: #4b5563; margin-top: 0.06in; }\n .cert-presented { font-size: 10pt; color: #4b5563; margin-top: 0.24in; letter-spacing: 0.16em; text-transform: uppercase; }\n .cert-recipient { font-size: 26pt; font-family: 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif; margin-top: 0.06in; padding-bottom: 0.06in; border-bottom: 1px solid #b08b3f; min-width: 3.4in; }\n .cert-body { font-size: 10.5pt; line-height: 1.6; margin-top: 0.2in; }\n .cert-date { font-size: 10pt; color: #4b5563; margin-top: 0.2in; }\n`;\n}\n\nfunction renderCoverPage(spec: WorkbookSpec): string {\n return `<div class=\"wb-cover\">\n <div class=\"wb-cover-title\">${escapeHtml(spec.title)}</div>\n <div class=\"wb-cover-rule\"></div>\n ${spec.subtitle ? `<div class=\"wb-cover-subtitle\">${escapeHtml(spec.subtitle)}</div>` : \"\"}\n</div>`;\n}\n\nfunction renderSectionTitlePage(section: WorkbookSection, index: number): string {\n return `<div class=\"wb-section-title-page\" data-section=\"${index + 1}\">\n <div class=\"wb-section-eyebrow\">Section ${index + 1}</div>\n <div class=\"wb-section-name\">${escapeHtml(section.title)}</div>\n</div>`;\n}\n\nfunction renderTocPage(\n spec: WorkbookSpec,\n entries: readonly { title: string; page: number }[],\n): string {\n const rows = entries\n .map(\n (entry) => `<div class=\"wb-toc-row\" data-toc-page=\"${entry.page}\">\n <span class=\"wb-toc-label\">${escapeHtml(entry.title)}</span>\n <span class=\"wb-toc-dots\"></span>\n <span class=\"wb-toc-page\">${entry.page}</span>\n </div>`,\n )\n .join(\"\");\n return `<div class=\"wb-toc\">\n <h2>${escapeHtml(spec.title)} — Contents</h2>\n ${rows}\n</div>`;\n}\n\n/**\n * Compose a workbook into per-page HTML fragments, in reading order:\n *\n * cover → [table of contents] → (section title page + its content pages)* → [final page]\n *\n * PURE: no `window`, no DOM, no mutable module state — safe on a server, and\n * the exact array it returns is what the booklet lane hands to `printBooklet`.\n *\n * The TOC page numbers are COMPUTED from the same layout this function emits:\n * page 1 is the cover, the TOC (when present) is page 2, and each section's\n * number is the 1-based index of its own title page. Because the TOC itself\n * occupies a page, its presence is folded into the offset before any section\n * number is calculated — the classic off-by-one in every hand-built TOC.\n */\nexport function composeWorkbookPages(spec: WorkbookSpec): string[] {\n const hasToc = spec.includeToc === true;\n // Cover is page 1; the TOC, when present, is page 2. Sections start after.\n const firstSectionPage = hasToc ? 3 : 2;\n\n const entries: { title: string; page: number }[] = [];\n let cursor = firstSectionPage;\n for (const section of spec.sections) {\n entries.push({ title: section.title, page: cursor });\n cursor += 1 + section.pagesHtml.length; // title page + content pages\n }\n\n const pages: string[] = [renderCoverPage(spec)];\n if (hasToc) pages.push(renderTocPage(spec, entries));\n spec.sections.forEach((section, i) => {\n pages.push(renderSectionTitlePage(section, i));\n for (const pageHtml of section.pagesHtml) pages.push(pageHtml);\n });\n\n if (spec.finalPage) {\n pages.push(\n spec.finalPage.kind === \"certificate\"\n ? renderCertificatePage(spec.finalPage.data, \"formal\")\n : spec.finalPage.html,\n );\n }\n return pages;\n}\n\nexport interface WorkbookPrintOptions {\n /** true = saddle-stitch imposition via ./booklet; false/omitted = sequential. */\n booklet?: boolean;\n /** Extra CSS appended after the workbook styles — style the caller's pages. */\n pageStyles?: string;\n}\n\n/**\n * Print a workbook. Two lanes, one composer:\n * - `booklet: true` → the composed pages go straight to `printBooklet`\n * (2-up landscape imposition, fold-and-staple).\n * - otherwise → one portrait page per composed fragment, each with a\n * page break after it.\n */\nexport function printWorkbook(\n spec: WorkbookSpec,\n opts?: WorkbookPrintOptions,\n): PrintOutcome {\n const pages = composeWorkbookPages(spec);\n const extra = opts?.pageStyles ?? \"\";\n\n if (opts?.booklet === true) {\n return printBooklet(pages, {\n title: spec.title,\n pageStyles: `${WORKBOOK_STYLES}\\n${CERT_RESET_FOR_WORKBOOK()}\\n${extra}`,\n });\n }\n\n const body = pages\n .map(\n (html, i) => `<div class=\"wb-page\" data-page=\"${i + 1}\">${html}</div>`,\n )\n .join(\"\\n\");\n\n const doc = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <title>${escapeHtml(spec.title)}</title>\n <style>\n${WORKBOOK_SEQUENTIAL_STYLES}\n${extra}\n </style>\n</head>\n<body>\n <div class=\"screen-only\">\n <button class=\"wb-btn wb-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"wb-btn wb-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n${body}\n</body>\n</html>`;\n\n return openPrintWindow(doc, spec.title, \"width=920,height=760,scrollbars=yes\");\n}\n","/**\n * @ai-matrx/print/booklet — saddle-stitch booklet imposition.\n *\n * The education white space: no test-prep / worksheet tool offers \"turn this\n * into a fold-and-staple booklet\". This module is the whole capability —\n * the imposition math, the size guidance, and a 2-up landscape print lane\n * composed over ./core.\n *\n * Zero dependencies: pure arithmetic plus string composition. Import-inert —\n * `window` is touched only inside printBooklet, at call time.\n *\n * ── THE MATH ────────────────────────────────────────────────────────────────\n * A saddle-stitch booklet is a stack of sheets folded in half together and\n * stapled through the spine. Every sheet carries FOUR logical pages (two per\n * side), so the page count is padded up to a multiple of 4 with blanks.\n *\n * For sheet i (1-indexed, i = 1..N/4) of an N-page (padded) booklet:\n * front spread = [N − 2i + 2, 2i − 1] (left, right)\n * back spread = [2i, N − 2i + 1] (left, right)\n *\n * Worked anchor, N = 8:\n * sheet 1 → front [8, 1] back [2, 7]\n * sheet 2 → front [6, 3] back [4, 5]\n * Every spread's two page numbers sum to N + 1 — the invariant that makes the\n * fold work, and the cheapest way to check an imposition is right.\n *\n * ── THE DUPLEX TRAP ─────────────────────────────────────────────────────────\n * These spreads are 2-up LANDSCAPE. Ordinary duplex printing flips on the LONG\n * edge, which for a landscape sheet means the back side lands upside-down\n * relative to the front — the booklet folds into gibberish. There are exactly\n * two fixes: instruct the human to select short-edge (tumble) duplex, or\n * pre-rotate every back side 180°. A print-window lane cannot pre-rotate\n * reliably (browsers differ on how `@page`/transform interact with duplex), so\n * THIS lane instructs. `BookletImposition.requiresShortEdgeDuplex` exposes the\n * trap in the type system so a future PDF lane — which owns its own page\n * transforms — can take the pre-rotate branch instead of the instruction.\n */\n\nimport { openPrintWindow, type PrintOutcome } from \"./core\";\nimport { escapeHtml } from \"@ai-matrx/kit/html-escape\";\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/**\n * One physical sheet of paper. Each tuple is [left, right] as the reader sees\n * that side laid flat, in 1-based logical page numbers.\n * `0` means a blank pad page (a slot past the end of the supplied content).\n */\nexport interface BookletSheet {\n front: [number, number];\n back: [number, number];\n}\n\nexport interface BookletImposition {\n /** Supplied page count rounded UP to the next multiple of 4. */\n paddedPageCount: number;\n /** Sheets in print order: sheet 1 outermost, last sheet at the fold. */\n sheets: BookletSheet[];\n /**\n * true when ordinary long-edge duplex would flip the backs — consumer must\n * either instruct short-edge/tumble duplex or pre-rotate backs 180° (PDF\n * lanes). Always true for this 2-up landscape imposition.\n */\n requiresShortEdgeDuplex: true;\n}\n\nexport interface BookletPrintOptions {\n title?: string;\n /** CSS injected after the layout styles — style the caller's page content. */\n pageStyles?: string;\n}\n\n// ─── Imposition ──────────────────────────────────────────────────────────────\n\n/** Sheets carry four logical pages each; everything pads to this. */\nconst PAGES_PER_SHEET = 4;\n\n/**\n * Past roughly this many letter pages, a saddle-stitch booklet gets too thick\n * to fold cleanly and the creep (inner pages pushing out at the fore-edge)\n * becomes visible. Callers should offer \"print as a workbook instead\".\n */\nconst SADDLE_STITCH_MAX_PAGES = 48;\n\n/**\n * Impose `pageCount` logical pages onto saddle-stitch sheets.\n *\n * Padding is appended at the END of the logical page run, so pad slots always\n * carry the HIGHEST page numbers — which is why they surface as `0` in the\n * spreads nearest the front cover (N=1 → sheet 1 front [0, 1], back [0, 0]).\n *\n * @throws RangeError when pageCount is not a positive integer.\n */\nexport function imposeBooklet(pageCount: number): BookletImposition {\n if (!Number.isInteger(pageCount) || pageCount < 1) {\n throw new RangeError(\n `imposeBooklet: pageCount must be a positive integer, received ${String(pageCount)}`,\n );\n }\n\n const padded = Math.ceil(pageCount / PAGES_PER_SHEET) * PAGES_PER_SHEET;\n // A slot past the supplied content is a blank pad page, reported as 0.\n const slot = (n: number): number => (n > pageCount ? 0 : n);\n\n const sheets: BookletSheet[] = [];\n for (let i = 1; i <= padded / PAGES_PER_SHEET; i += 1) {\n sheets.push({\n front: [slot(padded - 2 * i + 2), slot(2 * i - 1)],\n back: [slot(2 * i), slot(padded - 2 * i + 1)],\n });\n }\n\n return { paddedPageCount: padded, sheets, requiresShortEdgeDuplex: true };\n}\n\n/**\n * Human warning when a booklet is too fat to saddle-stitch well, else null.\n * Measured on the PADDED count — that is what actually goes through the folder.\n */\nexport function bookletSizeWarning(pageCount: number): string | null {\n if (!Number.isInteger(pageCount) || pageCount < 1) return null;\n const padded = Math.ceil(pageCount / PAGES_PER_SHEET) * PAGES_PER_SHEET;\n if (padded <= SADDLE_STITCH_MAX_PAGES) return null;\n return (\n `This booklet is ${padded} pages (${padded / PAGES_PER_SHEET} sheets). ` +\n `Past about ${SADDLE_STITCH_MAX_PAGES} pages a saddle-stitch booklet gets too thick to fold flat ` +\n `and the inner pages creep out at the fore-edge. Consider printing it as a plain workbook instead, ` +\n `or splitting it into ${SADDLE_STITCH_MAX_PAGES}-page signatures.`\n );\n}\n\n// ─── Print lane ──────────────────────────────────────────────────────────────\n\nconst BOOKLET_STYLES = `\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n @page { size: 11in 8.5in; margin: 0; }\n\n html, body {\n background: #fff;\n color: #1a1a1a;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;\n }\n\n .bk-side {\n display: flex;\n flex-direction: row;\n width: 11in;\n height: 8.5in;\n page-break-after: always;\n break-after: page;\n overflow: hidden;\n }\n .bk-side:last-of-type { page-break-after: auto; break-after: auto; }\n\n .bk-page {\n width: 5.5in;\n height: 8.5in;\n overflow: hidden;\n position: relative;\n }\n .bk-blank { /* intentionally empty — a padded slot */ }\n\n .bk-banner {\n background: #fef3c7;\n border: 2px solid #f59e0b;\n border-radius: 8px;\n padding: 14px 18px;\n margin: 18px;\n font-size: 13px;\n line-height: 1.55;\n color: #78350f;\n max-width: 11in;\n }\n .bk-banner h2 { font-size: 15px; margin-bottom: 6px; }\n .bk-banner ol { margin: 6px 0 6px 20px; }\n .bk-banner strong { font-weight: 700; }\n .bk-banner .bk-size-warning {\n display: block;\n margin-top: 10px;\n padding-top: 10px;\n border-top: 1px solid #f59e0b;\n color: #7f1d1d;\n font-weight: 600;\n }\n .bk-actions { display: flex; gap: 10px; margin: 0 18px 18px; }\n .bk-btn {\n padding: 8px 20px; border-radius: 8px; font-size: 13px; font-family: inherit;\n font-weight: 600; cursor: pointer; border: none;\n }\n .bk-btn-primary { background: #2563eb; color: #fff; }\n .bk-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }\n\n @media print {\n .screen-only { display: none !important; }\n .bk-side { page-break-after: always; break-after: page; }\n }\n`;\n\nfunction renderBox(page: number, pagesHtml: readonly string[]): string {\n // Content fragments are TRUSTED HTML supplied by the caller — they are\n // already-rendered pages, so they are injected verbatim and NOT escaped.\n const html = page === 0 ? undefined : pagesHtml[page - 1];\n if (html === undefined) {\n return `<div class=\"bk-page bk-blank\" data-page=\"0\"></div>`;\n }\n return `<div class=\"bk-page\" data-page=\"${page}\">${html}</div>`;\n}\n\nfunction renderSide(spread: readonly [number, number], pagesHtml: readonly string[]): string {\n return `<section class=\"bk-side\">${renderBox(spread[0], pagesHtml)}${renderBox(spread[1], pagesHtml)}</section>`;\n}\n\nfunction renderBanner(imposition: BookletImposition, warning: string | null): string {\n const sheetCount = imposition.sheets.length;\n const warningHtml = warning\n ? `<span class=\"bk-size-warning\">${escapeHtml(warning)}</span>`\n : \"\";\n return `<div class=\"bk-banner screen-only\">\n <h2>Booklet print settings — set these exactly</h2>\n <ol>\n <li>Orientation: <strong>Landscape</strong></li>\n <li>Two-sided printing: <strong>ON</strong></li>\n <li>Flip on <strong>SHORT edge</strong> (also called \"tumble\" or \"short-edge binding\") — long-edge flip prints every back side upside-down</li>\n <li>Scale: <strong>100%</strong> (never \"fit to page\")</li>\n <li>Margins: <strong>None</strong></li>\n </ol>\n <p><strong>Print all pages, fold the whole stack in half, staple the spine.</strong>\n ${sheetCount} sheet${sheetCount === 1 ? \"\" : \"s\"} of paper, ${imposition.paddedPageCount} booklet pages.</p>\n ${warningHtml}\n </div>`;\n}\n\n/**\n * Compose the full booklet document.\n *\n * `pagesHtml[i]` is the already-rendered HTML for logical page i+1. Those\n * fragments are TRUSTED and injected verbatim (they are rendered pages, and\n * escaping them would print markup). Only the title is escaped.\n */\nexport function buildBookletDocument(\n pagesHtml: readonly string[],\n opts?: BookletPrintOptions,\n): string {\n const title = opts?.title ?? \"Booklet\";\n const pageStyles = opts?.pageStyles ?? \"\";\n const imposition = imposeBooklet(Math.max(1, pagesHtml.length));\n const warning = bookletSizeWarning(pagesHtml.length);\n\n // Sheet order: sheet1 front, sheet1 back, sheet2 front, ... — print the whole\n // run, then fold the stack as one.\n const sides = imposition.sheets\n .map((sheet) => renderSide(sheet.front, pagesHtml) + renderSide(sheet.back, pagesHtml))\n .join(\"\\n\");\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(title)}</title>\n <style>\n${BOOKLET_STYLES}\n${pageStyles}\n </style>\n</head>\n<body>\n${renderBanner(imposition, warning)}\n <div class=\"bk-actions screen-only\">\n <button class=\"bk-btn bk-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"bk-btn bk-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n${sides}\n</body>\n</html>`;\n}\n\n/** Build the booklet document and open it in a print window. */\nexport function printBooklet(\n pagesHtml: readonly string[],\n opts?: BookletPrintOptions,\n): PrintOutcome {\n const title = opts?.title ?? \"Booklet\";\n return openPrintWindow(buildBookletDocument(pagesHtml, opts), title);\n}\n"],"mappings":"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","names":["escapeHtml","escapeHtml","escapeHtml"]}
|
|
1
|
+
{"version":3,"sources":["../src/core.ts","../src/katex-css.generated.ts","../src/certificate.ts","../src/booklet.ts"],"sourcesContent":["/**\n * @ai-matrx/print/core — the Block Print contract & print-window composition.\n *\n * Ported VERBATIM from matrx-frontend `lib/block-print/block-print-utils.ts`\n * (2026-08-29 state, including the `number` and `select` PrintSetting types the\n * commerce label lane added). Zero dependencies; touches `window` only at call\n * time, so the module stays import-inert and RSC-safe.\n *\n * BlockPrinter is the only interface a host system needs to know about.\n * Each printable unit exports its own BlockPrinter alongside its render\n * component; host code only ever calls print(data, variantId, settings).\n *\n * `buildFitTextScript` is the ONE auto-shrink script (the flashcards\n * FIT_TEXT pattern, parameterized by selector so the label lane shares it).\n */\n\n// THE ONE HTML escaper (duplication census row 14). Nine copies of\n// `escapeHtml` were live across the fleet and THEY DID NOT ESCAPE THE SAME\n// CHARACTERS — a string safe through one was an attribute break-out through\n// another. It moved to `@ai-matrx/kit/html-escape` with a decided character\n// set: an HTML escaper must not require this package's jspdf + html2canvas\n// graph. Not re-exported from here; import `@ai-matrx/kit/html-escape`.\nimport { downloadFile } from \"@ai-matrx/kit/download\";\nimport { escapeHtml } from \"@ai-matrx/kit/html-escape\";\nimport { KATEX_CSS, KATEX_CSS_MARKER } from \"./katex-css.generated\";\nimport { openOutbound } from \"@ai-matrx/kit/outbound\";\n\n// ─── Contract ────────────────────────────────────────────────────────────────\n\nexport interface PrintVariant {\n id: string;\n label: string;\n description?: string;\n}\n\n/**\n * A single user-configurable setting shown in the print dialog.\n * Extend the discriminated union for new control types\n * without breaking existing printers.\n */\nexport type PrintSetting =\n | {\n type: \"boolean\";\n id: string;\n label: string;\n description?: string;\n /** Default value shown when the dialog first opens */\n defaultValue: boolean;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /**\n * A numeric \"from → to\" range control, rendered as two small number inputs.\n * Writes two entries into the resolved PrintSettings map — one under fromId,\n * one under toId. Printers interpret 0 (or missing) as \"no bound\" by convention.\n */\n type: \"range\";\n /** Stable id used as React key — not written to the settings map */\n id: string;\n label: string;\n description?: string;\n /** Settings map key for the lower bound value */\n fromId: string;\n /** Settings map key for the upper bound value */\n toId: string;\n defaultFrom: number;\n defaultTo: number;\n /** Minimum accepted value for each input (inclusive). Default 0. */\n min?: number;\n /** Placeholder shown when the input is empty (0). Default \"start\" / \"end\". */\n fromPlaceholder?: string;\n toPlaceholder?: string;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /** A single numeric input (e.g. \"start at label #\", \"copies\"). */\n type: \"number\";\n id: string;\n label: string;\n description?: string;\n defaultValue: number;\n /** Minimum accepted value (inclusive). Default 0. */\n min?: number;\n /** Maximum accepted value (inclusive). Omit = unbounded. */\n max?: number;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n }\n | {\n /** A one-of-N choice rendered as a compact segmented row. */\n type: \"select\";\n id: string;\n label: string;\n description?: string;\n options: { value: string; label: string }[];\n defaultValue: string;\n /** Which variant IDs this setting applies to. Omit = applies to all. */\n appliesTo?: string[];\n };\n\n/** A resolved map of setting id → current value, passed to print(). */\nexport type PrintSettings = Record<string, boolean | string | number>;\n\n/**\n * The self-contained print capability that each block can export.\n * Host code only ever calls print(data, variantId, settings) — it never inspects internals.\n */\nexport interface BlockPrinter {\n /** Tooltip / aria-label for the print button in the block header */\n label: string;\n\n /**\n * Variants offered to the user before printing.\n * Empty array = no options dialog shown; prints immediately with defaults.\n */\n variants: PrintVariant[];\n\n /**\n * Optional user-configurable settings shown below the variant picker.\n * Omit entirely if the block has no configurable settings.\n */\n settings?: PrintSetting[];\n\n /**\n * Execute the print.\n * data — the block's structured data (serverData or parsed content)\n * variantId — which variant was chosen (undefined = default / no variants)\n * settings — resolved setting values chosen by the user\n *\n * MAY resolve to a PrintOutcome (\"opened\" | \"downloaded\") so the host can\n * tell the user when the popup-blocked download fallback fired; `void`\n * stays legal for printers that don't go through a print window.\n */\n print: (\n data: unknown,\n variantId?: string,\n settings?: PrintSettings,\n ) => void | PrintOutcome | Promise<void | PrintOutcome>;\n\n /**\n * The block as it appears INSIDE a composed print (a message's Print / Full\n * Print, a document export): the same adapter the block's own print uses,\n * returning body HTML or a picture. A frame block (an HTML page) answers\n * with an image — a composed print window runs no author scripts — or an\n * honest `{ notice }` when no picture can be made. Absent ⇒ the composer\n * prints the block's source.\n */\n toPrintHtml?: (data: unknown, context: PrintBlockContext) => PrintBlockOutput | null | Promise<PrintBlockOutput | null>;\n}\n\n/** Where a block sits when a composer asks for its print form. */\nexport interface PrintBlockContext {\n /** The registry key the block was found under (artifact type or `__kind`). */\n readonly type: string;\n /** The block's raw source text, exactly as it sits in the message. */\n readonly raw: string;\n /** The block's title, when the wire form carries one. */\n readonly title?: string | undefined;\n /** The wire form's attributes (an `<artifact>`'s `id`, `version`) — how a printer finds the block's record. */\n readonly attributes?: Readonly<Record<string, string>> | undefined;\n}\n\n/**\n * A block's print form inside a composed print. `html` is trusted body HTML\n * from a printer (escape what you interpolate); `image` is a picture (a\n * captured page, a drawn diagram); `notice` is an honest line printed in the\n * block's place when neither exists.\n */\nexport type PrintBlockOutput =\n | {\n readonly html: string;\n /**\n * The block's stylesheet. A composed print sanitizes block HTML (no\n * `<style>`, no inline styles, only `matrx-*` / `print-*` classes), so a\n * printer that styles its layout passes its CSS here: the composer puts\n * each distinct sheet once in the document head. Scope it under your own\n * `matrx-…` class.\n */\n readonly css?: string | undefined;\n }\n | { readonly image: { readonly src: string; readonly alt?: string | undefined; readonly caption?: string | undefined } }\n | { readonly notice: string };\n\n// ─── The ONE block-printer registry ───────────────────────────────────────────\n//\n// Keyed by artifact type / `__kind`. A block's own print button, the canvas\n// tab's Print and the message composer all read the same entry, so a block\n// has one print adapter with every caller. Lives on globalThis under a\n// Symbol.for key so duplicated bundles (ESM + CJS) share one registry.\n\nconst PRINTER_REGISTRY = Symbol.for(\"ai-matrx.print.block-printers\");\n\nfunction printerRegistry(): Map<string, BlockPrinter> {\n const g = globalThis as unknown as { [PRINTER_REGISTRY]?: Map<string, BlockPrinter> };\n g[PRINTER_REGISTRY] ??= new Map();\n return g[PRINTER_REGISTRY];\n}\n\n/** Registers `printer` under each key (artifact type and/or `__kind`). Returns the unregister. */\nexport function registerBlockPrinter(keys: string | readonly string[], printer: BlockPrinter): () => void {\n const map = printerRegistry();\n const list = (typeof keys === \"string\" ? [keys] : keys).map((key) => key.toLowerCase());\n for (const key of list) map.set(key, printer);\n return () => {\n for (const key of list) if (map.get(key) === printer) map.delete(key);\n };\n}\n\n/** The printer registered for an artifact type or `__kind` (case-insensitive). */\nexport function getBlockPrinter(key: string | null | undefined): BlockPrinter | undefined {\n return key ? printerRegistry().get(key.toLowerCase()) : undefined;\n}\n\n// ─── Lazy printer loaders ─────────────────────────────────────────────────────\n//\n// A host may load its printers lazily (they carry KaTeX and every structured\n// layout, so they stay out of a page's first load). A lookup that is allowed to\n// wait — a message print, which already awaits its own imports — calls\n// `whenBlockPrintersReady()` first, so a print clicked before the host's\n// background load finishes still finds every printer instead of silently\n// printing a block as its source. Shared on globalThis like the registry.\n\nconst PRINTER_LOADERS = Symbol.for(\"ai-matrx.print.block-printer-loaders\");\n\nfunction printerLoaders(): Set<() => Promise<unknown>> {\n const g = globalThis as unknown as { [PRINTER_LOADERS]?: Set<() => Promise<unknown>> };\n g[PRINTER_LOADERS] ??= new Set();\n return g[PRINTER_LOADERS];\n}\n\n/** Registers a host's lazy printer loader (it must be idempotent). Returns the unregister. */\nexport function registerBlockPrinterLoader(load: () => Promise<unknown>): () => void {\n const loaders = printerLoaders();\n loaders.add(load);\n return () => {\n loaders.delete(load);\n };\n}\n\n/**\n * Resolves once every registered printer loader has finished. A loader that\n * fails is reported, never thrown: the print still goes out, with that block\n * printed as its source.\n */\nexport async function whenBlockPrintersReady(): Promise<void> {\n await Promise.all(\n [...printerLoaders()].map((load) =>\n load().then(\n () => undefined,\n (error: unknown) => {\n console.error(\"[print] a block-printer loader failed; its blocks print as source\", error);\n },\n ),\n ),\n );\n}\n\n/** Every registered key — for guards and diagnostics. */\nexport function listBlockPrinterKeys(): string[] {\n return [...printerRegistry().keys()];\n}\n\n/** The HTML a composer puts in a block's place for a `PrintBlockOutput`. */\nexport function printBlockOutputHtml(output: PrintBlockOutput): string {\n if (\"html\" in output) return `<div class=\"matrx-print-block\">${output.html}</div>`;\n if (\"image\" in output) {\n const { src, alt, caption } = output.image;\n return `<figure class=\"matrx-print-block matrx-print-image\"><img src=\"${escapeHtml(src)}\" alt=\"${escapeHtml(\n alt ?? \"Picture\",\n )}\" style=\"max-width:100%;height:auto\">${caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : \"\"}</figure>`;\n }\n return `<p class=\"matrx-print-block matrx-print-notice\" style=\"border:1px dashed #94a3b8;border-radius:6px;padding:10px 12px;color:#475569;font-style:italic\">${escapeHtml(\n output.notice,\n )}</p>`;\n}\n\n// ─── Shared Utilities ─────────────────────────────────────────────────────────\n\nconst PRINT_STYLES = `\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;\n font-size: 11pt;\n line-height: 1.6;\n color: #1a1a1a;\n background: #fff;\n max-width: 860px;\n margin: 0 auto;\n padding: 28px 36px;\n }\n\n .print-actions {\n display: flex;\n gap: 10px;\n margin-bottom: 24px;\n padding-bottom: 18px;\n border-bottom: 2px solid #e5e7eb;\n }\n\n .print-btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 8px 20px;\n border-radius: 8px;\n font-size: 13px;\n font-family: inherit;\n cursor: pointer;\n border: none;\n font-weight: 600;\n }\n\n .print-btn-primary { background: #2563eb; color: #fff; }\n .print-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }\n\n h1 { font-size: 22pt; font-weight: 700; margin-bottom: 0.5em; }\n h2 { font-size: 16pt; font-weight: 700; margin: 1.2em 0 0.4em; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.2em; }\n h3 { font-size: 13pt; font-weight: 600; margin: 1em 0 0.3em; }\n p { margin-bottom: 0.7em; }\n\n table {\n width: 100%;\n border-collapse: collapse;\n margin: 1em 0;\n font-size: 10pt;\n }\n th {\n background: #1e293b;\n color: #f8fafc;\n font-weight: 600;\n text-align: left;\n padding: 7px 12px;\n border: 1px solid #334155;\n }\n td {\n padding: 6px 12px;\n border: 1px solid #e2e8f0;\n vertical-align: top;\n }\n tr:nth-child(even) td { background: #f8fafc; }\n\n @media print {\n body { max-width: 100%; padding: 0; }\n .print-actions { display: none !important; }\n th { -webkit-print-color-adjust: exact; print-color-adjust: exact; }\n }\n`;\n\n/**\n * Wrap body HTML in a complete, styled print document.\n */\nexport function buildPrintDocument(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(title)}</title>\n <style>\n${PRINT_STYLES}\n${extraStyles}\n </style>\n</head>\n<body>\n <div class=\"print-actions\">\n <button class=\"print-btn print-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"print-btn print-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n <div class=\"content\">\n${bodyHtml}\n </div>\n</body>\n</html>`;\n}\n\n/**\n * How a print request actually reached the user.\n *\n * \"opened\" — a print window/tab was opened and the document written into it.\n * \"downloaded\" — the popup was blocked, so the document was downloaded as an\n * .html file instead (the contract's fallback). Hosts SHOULD\n * surface this to the user (e.g. a toast saying the print file\n * was downloaded) — a blocked popup plus a silent download is\n * indistinguishable from \"nothing happened\", which is exactly\n * how QA filed it (2026-08-30, F5/F6/F8).\n */\nexport type PrintOutcome = \"opened\" | \"downloaded\";\n\n/**\n * Open a new browser window and write a complete HTML document into it.\n * Falls back to downloading an .html file if the popup is blocked.\n *\n * Returns which path was taken so callers can tell the user when the\n * fallback fired. `windowFeatures` customizes the popup geometry (the\n * flashcards fixed-geometry variants pass a wider window).\n */\n/**\n * Every printed document leaves through here, so this is where typeset math\n * gets its stylesheet: when the page carries KaTeX output and does not already\n * carry the sheet, it is added to <head>. A page with no math is untouched,\n * byte for byte (the flashcard golden master holds every printer to that).\n */\nexport function withMathStyles(htmlDocument: string): string {\n if (!htmlDocument.includes('class=\"katex\"') || htmlDocument.includes(KATEX_CSS_MARKER)) {\n return htmlDocument;\n }\n const style = `<style data-matrx-katex>${KATEX_CSS}</style>`;\n const head = htmlDocument.search(/<\\/head>/i);\n return head >= 0\n ? htmlDocument.slice(0, head) + style + htmlDocument.slice(head)\n : style + htmlDocument;\n}\n\n/**\n * THE HOST PRINT SEAM. A host with its own printing (Matrx 2: Electron's\n * `webContents.print` in a hidden window, which denies the web's print popup)\n * plugs it in once at start-up. Every print door below then hands the host the\n * EXACT document the web's print window would show (same printable HTML, math\n * styles included) instead of opening a popup or downloading a file, and\n * reports \"opened\". The host owns its outcome from there: when its print\n * fails it tells the person in plain words (the package cannot see past the\n * hand-off). A host that throws synchronously falls back to the web path.\n * Without a host (the web), nothing changes.\n */\nexport interface PrintHostRequest {\n /** The document's title (also the base of a download/PDF filename). */\n title: string;\n}\n\nexport type PrintHost = (htmlDocument: string, request: PrintHostRequest) => void | Promise<void>;\n\n// Symbol.for so duplicated bundles (ESM + CJS, two package copies) share one host.\nconst PRINT_HOST = Symbol.for(\"ai-matrx.print.host\");\n\ntype PrintHostHolder = { [PRINT_HOST]?: PrintHost | null };\n\n/** Plugs (or, with null, removes) the host's native print. */\nexport function setPrintHost(host: PrintHost | null): void {\n (globalThis as unknown as PrintHostHolder)[PRINT_HOST] = host;\n}\n\nexport function getPrintHost(): PrintHost | null {\n return (globalThis as unknown as PrintHostHolder)[PRINT_HOST] ?? null;\n}\n\n/** True when the host took the document; false when it has none or threw (the caller takes the web path). */\nfunction handToPrintHost(htmlDocument: string, title: string): boolean {\n const host = getPrintHost();\n if (!host) return false;\n try {\n const done = host(htmlDocument, { title });\n if (done && typeof (done as Promise<void>).catch === \"function\") {\n (done as Promise<void>).catch((error: unknown) => console.error(\"[@ai-matrx/print] the host's print failed\", error));\n }\n return true;\n } catch (error) {\n console.error(\"[@ai-matrx/print] the host's print threw; using the print window instead\", error);\n return false;\n }\n}\n\nexport function openPrintWindow(\n rawDocument: string,\n filename = \"print\",\n windowFeatures = \"width=920,height=720,scrollbars=yes\",\n): PrintOutcome {\n const htmlDocument = withMathStyles(rawDocument);\n if (handToPrintHost(htmlDocument, filename)) return \"opened\";\n const win = openOutbound(\"\", \"_blank\", windowFeatures);\n // Popup blocked — fall back to file download.\n if (!win) return openPrintWindowFallback(htmlDocument, filename);\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n return \"opened\";\n}\n\n/** A print window opened before its document is ready (see `openPendingPrintWindow`). */\nexport interface PendingPrintWindow {\n /** Write the finished document; downloads it when the popup was blocked or closed. */\n write(htmlDocument: string): PrintOutcome;\n}\n\n/**\n * Open the print window NOW — inside the click, where popup blockers allow it —\n * and write the document later, once async preparation (drawing diagrams) is\n * done. The window says \"Preparing…\" meanwhile.\n */\nexport function openPendingPrintWindow(\n filename = \"print\",\n windowFeatures = \"width=920,height=720,scrollbars=yes\",\n): PendingPrintWindow {\n // A host that prints natively needs no window held open: it takes the finished document.\n const win = getPrintHost() ? null : openOutbound(\"\", \"_blank\", windowFeatures);\n if (win) {\n win.document.open();\n win.document.write(\n '<!DOCTYPE html><html><head><meta charset=\"UTF-8\"><title>Preparing…</title></head><body style=\"font:14px -apple-system,BlinkMacSystemFont,sans-serif;color:#555;padding:2rem\">Preparing the document for print…</body></html>',\n );\n win.document.close();\n }\n return {\n write(rawDocument: string): PrintOutcome {\n const htmlDocument = withMathStyles(rawDocument);\n if (!win && handToPrintHost(htmlDocument, filename)) return \"opened\";\n if (!win || win.closed) return openPrintWindowFallback(htmlDocument, filename);\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n return \"opened\";\n },\n };\n}\n\nfunction openPrintWindowFallback(htmlDocument: string, filename: string): PrintOutcome {\n downloadFile(`${filename.replace(/\\s+/g, \"-\").toLowerCase()}.html`, htmlDocument, \"text/html;charset=utf-8\");\n return \"downloaded\";\n}\n\n/**\n * Shorthand: build the document and open it in one call.\n * Returns the same PrintOutcome as openPrintWindow.\n */\nexport function printHtmlContent(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): PrintOutcome {\n const doc = buildPrintDocument(bodyHtml, title, extraStyles);\n return openPrintWindow(doc, title);\n}\n\n/**\n * Build the auto-shrink script that runs INSIDE a printed window: for every\n * element matching each selector, its font-size is reduced (0.5pt steps, 6pt\n * floor) until it fits its parent element's box. This is the flashcards\n * FIT_TEXT behavior verbatim, parameterized by selector so every lane\n * (flashcards card text, Avery text, label captions) shares the ONE loop.\n *\n * Pass the same selectors the original used to get byte-identical behavior:\n * flashcards passes `[\".card-text .card-text-inner\", \".avery-text-wrap .avery-text\"]`.\n */\nexport function buildFitTextScript(selectors: readonly string[]): string {\n const selectorsJson = JSON.stringify([...selectors]);\n return `\n<script>\n(function() {\n function fitText(el, container) {\n // container is the bounding box we must fit inside\n var maxW = container.clientWidth;\n var maxH = container.clientHeight;\n var size = parseFloat(window.getComputedStyle(el).fontSize);\n var minSize = 6;\n // Give the element a chance to reflow at current size before measuring\n el.style.fontSize = size + \"pt\";\n while (size > minSize && (el.scrollWidth > maxW + 1 || el.scrollHeight > maxH + 1)) {\n size -= 0.5;\n el.style.fontSize = size + \"pt\";\n }\n }\n function runAll() {\n var selectors = ${selectorsJson};\n for (var s = 0; s < selectors.length; s++) {\n var els = document.querySelectorAll(selectors[s]);\n for (var i = 0; i < els.length; i++) {\n fitText(els[i], els[i].parentElement);\n }\n }\n }\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", runAll);\n } else {\n // document.write pages may already be loaded\n setTimeout(runAll, 0);\n }\n})();\n</script>`;\n}\n\n\n// ─── DOM print: a rendered body, as it looks ─────────────────────────────────\n\nexport interface PrintElementOptions {\n /** The print window's title (and the download fallback's file name). */\n title?: string;\n /** Open the system print dialog once the window has loaded. Default true. */\n autoPrint?: boolean;\n /** Keep buttons, toolbars and menus in the print (default false: they are chrome, not content). */\n keepInteractive?: boolean;\n}\n\n/**\n * Interactive chrome that is not content: anything marked `data-print-hide`,\n * toolbars and menus, and buttons (\"Chart this\", \"Save to\", icon actions).\n * A button stays when it IS content — marked `data-print-keep`, or a disclosure\n * trigger (`aria-expanded`) whose label is a heading — and so does anything\n * inside a `data-print-keep` region. Removal runs on the print clone only.\n */\nconst PRINT_CHROME_SELECTOR = [\n \"[data-print-hide]\",\n '[role=\"toolbar\"]',\n '[role=\"menu\"]',\n '[role=\"menubar\"]',\n \"button\",\n '[role=\"button\"]',\n 'input[type=\"button\"]',\n 'input[type=\"submit\"]',\n 'input[type=\"reset\"]',\n].join(\",\");\n\nexport function stripInteractiveChrome(root: ParentNode): number {\n let removed = 0;\n root.querySelectorAll(PRINT_CHROME_SELECTOR).forEach((node) => {\n if (!root.contains(node)) return; // already gone with a removed ancestor\n const explicit = node.hasAttribute(\"data-print-hide\");\n if (!explicit) {\n if (node.closest(\"[data-print-keep]\")) return;\n if (node.matches(\"button, [role=\\\"button\\\"]\") && node.hasAttribute(\"aria-expanded\")) return;\n }\n node.remove();\n removed += 1;\n });\n return removed;\n}\n\nconst ELEMENT_PRINT_CSS = `\n html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; background: #fff !important; }\n body { margin: 0; padding: 16px; }\n *, *::before, *::after { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }\n .matrx-print-root, .matrx-print-root * { max-height: none !important; }\n .matrx-print-root [class*=\"overflow-\"], .matrx-print-root [class*=\"h-full\"], .matrx-print-root [class*=\"min-h-0\"] { overflow: visible !important; height: auto !important; }\n .matrx-print-frame-notice { border: 1px dashed #94a3b8; border-radius: 6px; padding: 10px 12px; color: #475569; font: italic 12px sans-serif; }\n @media print { .matrx-print-root * { break-inside: auto; } img, svg, figure, tr { break-inside: avoid; } }\n`;\n\n/**\n * Prints a same-origin DOM body exactly as it is drawn: the element's markup\n * with the page's own stylesheets, light theme, backgrounds kept, scroll\n * containers opened to full height. A `<canvas>` prints as its picture; an\n * `<iframe>` cannot be copied and prints an honest notice in its place (a\n * frame kind declares its own print instead). Opens inside the click.\n */\nexport function printElement(element: HTMLElement, options: PrintElementOptions = {}): PrintOutcome {\n const title = options.title ?? (document.title || \"Print\");\n const head = [...document.querySelectorAll<HTMLLinkElement | HTMLStyleElement>('link[rel=\"stylesheet\"], style')]\n .map((node) => {\n if (node instanceof HTMLLinkElement) return `<link rel=\"stylesheet\" href=\"${escapeHtml(node.href)}\">`;\n return `<style>${node.textContent ?? \"\"}</style>`;\n })\n .join(\"\\n\");\n const clone = element.cloneNode(true) as HTMLElement;\n const liveCanvases = element.querySelectorAll(\"canvas\");\n clone.querySelectorAll(\"canvas\").forEach((canvas, i) => {\n const live = liveCanvases[i];\n const img = document.createElement(\"img\");\n try {\n img.src = live ? live.toDataURL(\"image/png\") : \"\";\n } catch {\n img.alt = \"This drawing cannot be copied for print.\";\n }\n img.style.maxWidth = \"100%\";\n canvas.replaceWith(img);\n });\n clone.querySelectorAll(\"iframe\").forEach((frame) => {\n const note = document.createElement(\"p\");\n note.className = \"matrx-print-frame-notice\";\n note.textContent = \"An embedded page is here — print it from its own Print.\";\n frame.replaceWith(note);\n });\n clone.querySelectorAll(\"script\").forEach((node) => node.remove());\n if (options.keepInteractive !== true) stripInteractiveChrome(clone);\n const htmlClass = document.documentElement.className.replace(/\\bdark\\b/g, \"light\");\n const autoPrint = options.autoPrint ?? true;\n const doc = `<!DOCTYPE html><html lang=\"en\" class=\"${escapeHtml(htmlClass)}\"><head><meta charset=\"UTF-8\"><base href=\"${escapeHtml(\n document.baseURI,\n )}\"><title>${escapeHtml(title)}</title>${head}<style>${ELEMENT_PRINT_CSS}</style></head><body><div class=\"matrx-print-root\">${\n clone.outerHTML\n }</div>${\n autoPrint\n ? '<script>window.addEventListener(\"load\",function(){setTimeout(function(){window.focus();window.print();},250);});</script>'\n : \"\"\n }</body></html>`;\n return openPrintWindow(doc, title);\n}\n","// GENERATED by scripts/gen-katex-css.mjs from katex@0.18.9 — do not edit.\nexport const KATEX_VERSION = \"0.18.9\";\n/** Leads KATEX_CSS; lets openPrintWindow see the sheet is already present. */\nexport const KATEX_CSS_MARKER = \"/* matrx-katex@0.18.9 */\";\nexport const KATEX_CSS = \"/* matrx-katex@0.18.9 */@font-face{font-display:block;font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_AMS-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_AMS-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_AMS-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Bold.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Bold.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Bold.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Caligraphic-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Bold.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Bold.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Bold.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Fraktur-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Bold.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Bold.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Bold.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-BoldItalic.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-BoldItalic.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-BoldItalic.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Italic.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Italic.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Italic.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Main-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-BoldItalic.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-BoldItalic.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-BoldItalic.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-Italic.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-Italic.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Math-Italic.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:\\\"KaTeX_SansSerif\\\";font-style:normal;font-weight:700;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Bold.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Bold.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Bold.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:\\\"KaTeX_SansSerif\\\";font-style:italic;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Italic.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Italic.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Italic.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:\\\"KaTeX_SansSerif\\\";font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_SansSerif-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Script-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Script-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Script-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size1-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size1-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size1-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size2-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size2-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size2-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size3-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size3-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size3-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size4-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size4-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Size4-Regular.ttf) format(\\\"truetype\\\")}@font-face{font-display:block;font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Typewriter-Regular.woff2) format(\\\"woff2\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Typewriter-Regular.woff) format(\\\"woff\\\"),url(https://cdn.jsdelivr.net/npm/katex@0.18.9/dist/fonts/KaTeX_Typewriter-Regular.ttf) format(\\\"truetype\\\")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;position:relative;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:\\\"0.18.9\\\"}.katex .katex-mathml{border:0;-webkit-clip-path:inset(50%);clip-path:inset(50%);height:1px;overflow:hidden;padding:0;position:absolute;width:1px}.katex .katex-html>.katex-newline{display:block}.katex .katex-base{position:relative;white-space:nowrap;width:-webkit-min-content;width:-moz-min-content;width:min-content}.katex .katex-base,.katex .katex-strut{display:inline-block}.katex .textbf{font-weight:700}.katex .textit{font-style:italic}.katex .textrm{font-family:KaTeX_Main}.katex .textsf{font-family:KaTeX_SansSerif}.katex .texttt{font-family:KaTeX_Typewriter}.katex .mathnormal{font-family:KaTeX_Math;font-style:italic}.katex .mathit{font-family:KaTeX_Main;font-style:italic}.katex .mathrm{font-style:normal}.katex .mathbf{font-family:KaTeX_Main;font-weight:700}.katex .boldsymbol{font-family:KaTeX_Math;font-style:italic;font-weight:700}.katex .amsrm,.katex .mathbb,.katex .textbb{font-family:KaTeX_AMS}.katex .mathcal{font-family:KaTeX_Caligraphic}.katex .mathfrak,.katex .textfrak{font-family:KaTeX_Fraktur}.katex .mathboldfrak,.katex .textboldfrak{font-family:KaTeX_Fraktur;font-weight:700}.katex .mathtt{font-family:KaTeX_Typewriter}.katex .mathscr,.katex .textscr{font-family:KaTeX_Script}.katex .mathsf,.katex .textsf{font-family:KaTeX_SansSerif}.katex .mathboldsf,.katex .textboldsf{font-family:KaTeX_SansSerif;font-weight:700}.katex .mathitsf,.katex .mathsfit,.katex .textitsf{font-family:KaTeX_SansSerif;font-style:italic}.katex .mainrm{font-family:KaTeX_Main;font-style:normal}.katex .vlist-t{border-collapse:collapse;display:inline-table;table-layout:fixed}.katex .vlist-r{display:table-row}.katex .vlist{display:table-cell;position:relative;vertical-align:bottom}.katex .vlist>span{display:block;height:0;position:relative}.katex .vlist>span>span{display:inline-block}.katex .vlist>span>.pstrut{overflow:hidden;width:0}.katex .vlist-t2{margin-right:-2px}.katex .vlist-s{display:table-cell;font-size:1px;min-width:2px;vertical-align:bottom;width:2px}.katex .katex-vbox{align-items:baseline;display:inline-flex;flex-direction:column}.katex .katex-thinbox{display:inline-flex;flex-direction:row;max-width:0;width:0}.katex .msupsub{text-align:left}.katex .mfrac>span>span{text-align:center}.katex .mfrac .frac-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .katex-hdashline,.katex .katex-hline,.katex .katex-overline .overline-line,.katex .katex-rule,.katex .katex-underline .underline-line,.katex .mfrac .frac-line{min-height:1px}.katex .mspace{display:inline-block}.katex .katex-smash{display:inline;line-height:0}.katex .clap,.katex .llap,.katex .rlap{position:relative;width:0}.katex .clap>.katex-inner,.katex .llap>.katex-inner,.katex .rlap>.katex-inner{position:absolute}.katex .clap>.katex-fix,.katex .llap>.katex-fix,.katex .rlap>.katex-fix{display:inline-block}.katex .llap>.katex-inner{right:0}.katex .clap>.katex-inner,.katex .rlap>.katex-inner{left:0}.katex .clap>.katex-inner>span{margin-left:-50%;margin-right:50%}.katex .katex-rule{border:0 solid;display:inline-block;position:relative}.katex .katex-hline,.katex .katex-overline .overline-line,.katex .katex-underline .underline-line{border-bottom-style:solid;display:inline-block;width:100%}.katex .katex-hdashline{border-bottom-style:dashed;display:inline-block;width:100%}.katex .sqrt>.katex-root{margin-left:.2777777778em;margin-right:-.5555555556em}.katex .fontsize-ensurer.reset-size1.size1,.katex .katex-sizing.reset-size1.size1{font-size:1em}.katex .fontsize-ensurer.reset-size1.size2,.katex .katex-sizing.reset-size1.size2{font-size:1.2em}.katex .fontsize-ensurer.reset-size1.size3,.katex .katex-sizing.reset-size1.size3{font-size:1.4em}.katex .fontsize-ensurer.reset-size1.size4,.katex .katex-sizing.reset-size1.size4{font-size:1.6em}.katex .fontsize-ensurer.reset-size1.size5,.katex .katex-sizing.reset-size1.size5{font-size:1.8em}.katex .fontsize-ensurer.reset-size1.size6,.katex .katex-sizing.reset-size1.size6{font-size:2em}.katex .fontsize-ensurer.reset-size1.size7,.katex .katex-sizing.reset-size1.size7{font-size:2.4em}.katex .fontsize-ensurer.reset-size1.size8,.katex .katex-sizing.reset-size1.size8{font-size:2.88em}.katex .fontsize-ensurer.reset-size1.size9,.katex .katex-sizing.reset-size1.size9{font-size:3.456em}.katex .fontsize-ensurer.reset-size1.size10,.katex .katex-sizing.reset-size1.size10{font-size:4.148em}.katex .fontsize-ensurer.reset-size1.size11,.katex .katex-sizing.reset-size1.size11{font-size:4.976em}.katex .fontsize-ensurer.reset-size2.size1,.katex .katex-sizing.reset-size2.size1{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size2.size2,.katex .katex-sizing.reset-size2.size2{font-size:1em}.katex .fontsize-ensurer.reset-size2.size3,.katex .katex-sizing.reset-size2.size3{font-size:1.1666666667em}.katex .fontsize-ensurer.reset-size2.size4,.katex .katex-sizing.reset-size2.size4{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size2.size5,.katex .katex-sizing.reset-size2.size5{font-size:1.5em}.katex .fontsize-ensurer.reset-size2.size6,.katex .katex-sizing.reset-size2.size6{font-size:1.6666666667em}.katex .fontsize-ensurer.reset-size2.size7,.katex .katex-sizing.reset-size2.size7{font-size:2em}.katex .fontsize-ensurer.reset-size2.size8,.katex .katex-sizing.reset-size2.size8{font-size:2.4em}.katex .fontsize-ensurer.reset-size2.size9,.katex .katex-sizing.reset-size2.size9{font-size:2.88em}.katex .fontsize-ensurer.reset-size2.size10,.katex .katex-sizing.reset-size2.size10{font-size:3.4566666667em}.katex .fontsize-ensurer.reset-size2.size11,.katex .katex-sizing.reset-size2.size11{font-size:4.1466666667em}.katex .fontsize-ensurer.reset-size3.size1,.katex .katex-sizing.reset-size3.size1{font-size:.7142857143em}.katex .fontsize-ensurer.reset-size3.size2,.katex .katex-sizing.reset-size3.size2{font-size:.8571428571em}.katex .fontsize-ensurer.reset-size3.size3,.katex .katex-sizing.reset-size3.size3{font-size:1em}.katex .fontsize-ensurer.reset-size3.size4,.katex .katex-sizing.reset-size3.size4{font-size:1.1428571429em}.katex .fontsize-ensurer.reset-size3.size5,.katex .katex-sizing.reset-size3.size5{font-size:1.2857142857em}.katex .fontsize-ensurer.reset-size3.size6,.katex .katex-sizing.reset-size3.size6{font-size:1.4285714286em}.katex .fontsize-ensurer.reset-size3.size7,.katex .katex-sizing.reset-size3.size7{font-size:1.7142857143em}.katex .fontsize-ensurer.reset-size3.size8,.katex .katex-sizing.reset-size3.size8{font-size:2.0571428571em}.katex .fontsize-ensurer.reset-size3.size9,.katex .katex-sizing.reset-size3.size9{font-size:2.4685714286em}.katex .fontsize-ensurer.reset-size3.size10,.katex .katex-sizing.reset-size3.size10{font-size:2.9628571429em}.katex .fontsize-ensurer.reset-size3.size11,.katex .katex-sizing.reset-size3.size11{font-size:3.5542857143em}.katex .fontsize-ensurer.reset-size4.size1,.katex .katex-sizing.reset-size4.size1{font-size:.625em}.katex .fontsize-ensurer.reset-size4.size2,.katex .katex-sizing.reset-size4.size2{font-size:.75em}.katex .fontsize-ensurer.reset-size4.size3,.katex .katex-sizing.reset-size4.size3{font-size:.875em}.katex .fontsize-ensurer.reset-size4.size4,.katex .katex-sizing.reset-size4.size4{font-size:1em}.katex .fontsize-ensurer.reset-size4.size5,.katex .katex-sizing.reset-size4.size5{font-size:1.125em}.katex .fontsize-ensurer.reset-size4.size6,.katex .katex-sizing.reset-size4.size6{font-size:1.25em}.katex .fontsize-ensurer.reset-size4.size7,.katex .katex-sizing.reset-size4.size7{font-size:1.5em}.katex .fontsize-ensurer.reset-size4.size8,.katex .katex-sizing.reset-size4.size8{font-size:1.8em}.katex .fontsize-ensurer.reset-size4.size9,.katex .katex-sizing.reset-size4.size9{font-size:2.16em}.katex .fontsize-ensurer.reset-size4.size10,.katex .katex-sizing.reset-size4.size10{font-size:2.5925em}.katex .fontsize-ensurer.reset-size4.size11,.katex .katex-sizing.reset-size4.size11{font-size:3.11em}.katex .fontsize-ensurer.reset-size5.size1,.katex .katex-sizing.reset-size5.size1{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size5.size2,.katex .katex-sizing.reset-size5.size2{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size5.size3,.katex .katex-sizing.reset-size5.size3{font-size:.7777777778em}.katex .fontsize-ensurer.reset-size5.size4,.katex .katex-sizing.reset-size5.size4{font-size:.8888888889em}.katex .fontsize-ensurer.reset-size5.size5,.katex .katex-sizing.reset-size5.size5{font-size:1em}.katex .fontsize-ensurer.reset-size5.size6,.katex .katex-sizing.reset-size5.size6{font-size:1.1111111111em}.katex .fontsize-ensurer.reset-size5.size7,.katex .katex-sizing.reset-size5.size7{font-size:1.3333333333em}.katex .fontsize-ensurer.reset-size5.size8,.katex .katex-sizing.reset-size5.size8{font-size:1.6em}.katex .fontsize-ensurer.reset-size5.size9,.katex .katex-sizing.reset-size5.size9{font-size:1.92em}.katex .fontsize-ensurer.reset-size5.size10,.katex .katex-sizing.reset-size5.size10{font-size:2.3044444444em}.katex .fontsize-ensurer.reset-size5.size11,.katex .katex-sizing.reset-size5.size11{font-size:2.7644444444em}.katex .fontsize-ensurer.reset-size6.size1,.katex .katex-sizing.reset-size6.size1{font-size:.5em}.katex .fontsize-ensurer.reset-size6.size2,.katex .katex-sizing.reset-size6.size2{font-size:.6em}.katex .fontsize-ensurer.reset-size6.size3,.katex .katex-sizing.reset-size6.size3{font-size:.7em}.katex .fontsize-ensurer.reset-size6.size4,.katex .katex-sizing.reset-size6.size4{font-size:.8em}.katex .fontsize-ensurer.reset-size6.size5,.katex .katex-sizing.reset-size6.size5{font-size:.9em}.katex .fontsize-ensurer.reset-size6.size6,.katex .katex-sizing.reset-size6.size6{font-size:1em}.katex .fontsize-ensurer.reset-size6.size7,.katex .katex-sizing.reset-size6.size7{font-size:1.2em}.katex .fontsize-ensurer.reset-size6.size8,.katex .katex-sizing.reset-size6.size8{font-size:1.44em}.katex .fontsize-ensurer.reset-size6.size9,.katex .katex-sizing.reset-size6.size9{font-size:1.728em}.katex .fontsize-ensurer.reset-size6.size10,.katex .katex-sizing.reset-size6.size10{font-size:2.074em}.katex .fontsize-ensurer.reset-size6.size11,.katex .katex-sizing.reset-size6.size11{font-size:2.488em}.katex .fontsize-ensurer.reset-size7.size1,.katex .katex-sizing.reset-size7.size1{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size7.size2,.katex .katex-sizing.reset-size7.size2{font-size:.5em}.katex .fontsize-ensurer.reset-size7.size3,.katex .katex-sizing.reset-size7.size3{font-size:.5833333333em}.katex .fontsize-ensurer.reset-size7.size4,.katex .katex-sizing.reset-size7.size4{font-size:.6666666667em}.katex .fontsize-ensurer.reset-size7.size5,.katex .katex-sizing.reset-size7.size5{font-size:.75em}.katex .fontsize-ensurer.reset-size7.size6,.katex .katex-sizing.reset-size7.size6{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size7.size7,.katex .katex-sizing.reset-size7.size7{font-size:1em}.katex .fontsize-ensurer.reset-size7.size8,.katex .katex-sizing.reset-size7.size8{font-size:1.2em}.katex .fontsize-ensurer.reset-size7.size9,.katex .katex-sizing.reset-size7.size9{font-size:1.44em}.katex .fontsize-ensurer.reset-size7.size10,.katex .katex-sizing.reset-size7.size10{font-size:1.7283333333em}.katex .fontsize-ensurer.reset-size7.size11,.katex .katex-sizing.reset-size7.size11{font-size:2.0733333333em}.katex .fontsize-ensurer.reset-size8.size1,.katex .katex-sizing.reset-size8.size1{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size8.size2,.katex .katex-sizing.reset-size8.size2{font-size:.4166666667em}.katex .fontsize-ensurer.reset-size8.size3,.katex .katex-sizing.reset-size8.size3{font-size:.4861111111em}.katex .fontsize-ensurer.reset-size8.size4,.katex .katex-sizing.reset-size8.size4{font-size:.5555555556em}.katex .fontsize-ensurer.reset-size8.size5,.katex .katex-sizing.reset-size8.size5{font-size:.625em}.katex .fontsize-ensurer.reset-size8.size6,.katex .katex-sizing.reset-size8.size6{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size8.size7,.katex .katex-sizing.reset-size8.size7{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size8.size8,.katex .katex-sizing.reset-size8.size8{font-size:1em}.katex .fontsize-ensurer.reset-size8.size9,.katex .katex-sizing.reset-size8.size9{font-size:1.2em}.katex .fontsize-ensurer.reset-size8.size10,.katex .katex-sizing.reset-size8.size10{font-size:1.4402777778em}.katex .fontsize-ensurer.reset-size8.size11,.katex .katex-sizing.reset-size8.size11{font-size:1.7277777778em}.katex .fontsize-ensurer.reset-size9.size1,.katex .katex-sizing.reset-size9.size1{font-size:.2893518519em}.katex .fontsize-ensurer.reset-size9.size2,.katex .katex-sizing.reset-size9.size2{font-size:.3472222222em}.katex .fontsize-ensurer.reset-size9.size3,.katex .katex-sizing.reset-size9.size3{font-size:.4050925926em}.katex .fontsize-ensurer.reset-size9.size4,.katex .katex-sizing.reset-size9.size4{font-size:.462962963em}.katex .fontsize-ensurer.reset-size9.size5,.katex .katex-sizing.reset-size9.size5{font-size:.5208333333em}.katex .fontsize-ensurer.reset-size9.size6,.katex .katex-sizing.reset-size9.size6{font-size:.5787037037em}.katex .fontsize-ensurer.reset-size9.size7,.katex .katex-sizing.reset-size9.size7{font-size:.6944444444em}.katex .fontsize-ensurer.reset-size9.size8,.katex .katex-sizing.reset-size9.size8{font-size:.8333333333em}.katex .fontsize-ensurer.reset-size9.size9,.katex .katex-sizing.reset-size9.size9{font-size:1em}.katex .fontsize-ensurer.reset-size9.size10,.katex .katex-sizing.reset-size9.size10{font-size:1.2002314815em}.katex .fontsize-ensurer.reset-size9.size11,.katex .katex-sizing.reset-size9.size11{font-size:1.4398148148em}.katex .fontsize-ensurer.reset-size10.size1,.katex .katex-sizing.reset-size10.size1{font-size:.2410800386em}.katex .fontsize-ensurer.reset-size10.size2,.katex .katex-sizing.reset-size10.size2{font-size:.2892960463em}.katex .fontsize-ensurer.reset-size10.size3,.katex .katex-sizing.reset-size10.size3{font-size:.337512054em}.katex .fontsize-ensurer.reset-size10.size4,.katex .katex-sizing.reset-size10.size4{font-size:.3857280617em}.katex .fontsize-ensurer.reset-size10.size5,.katex .katex-sizing.reset-size10.size5{font-size:.4339440694em}.katex .fontsize-ensurer.reset-size10.size6,.katex .katex-sizing.reset-size10.size6{font-size:.4821600771em}.katex .fontsize-ensurer.reset-size10.size7,.katex .katex-sizing.reset-size10.size7{font-size:.5785920926em}.katex .fontsize-ensurer.reset-size10.size8,.katex .katex-sizing.reset-size10.size8{font-size:.6943105111em}.katex .fontsize-ensurer.reset-size10.size9,.katex .katex-sizing.reset-size10.size9{font-size:.8331726133em}.katex .fontsize-ensurer.reset-size10.size10,.katex .katex-sizing.reset-size10.size10{font-size:1em}.katex .fontsize-ensurer.reset-size10.size11,.katex .katex-sizing.reset-size10.size11{font-size:1.1996142719em}.katex .fontsize-ensurer.reset-size11.size1,.katex .katex-sizing.reset-size11.size1{font-size:.2009646302em}.katex .fontsize-ensurer.reset-size11.size2,.katex .katex-sizing.reset-size11.size2{font-size:.2411575563em}.katex .fontsize-ensurer.reset-size11.size3,.katex .katex-sizing.reset-size11.size3{font-size:.2813504823em}.katex .fontsize-ensurer.reset-size11.size4,.katex .katex-sizing.reset-size11.size4{font-size:.3215434084em}.katex .fontsize-ensurer.reset-size11.size5,.katex .katex-sizing.reset-size11.size5{font-size:.3617363344em}.katex .fontsize-ensurer.reset-size11.size6,.katex .katex-sizing.reset-size11.size6{font-size:.4019292605em}.katex .fontsize-ensurer.reset-size11.size7,.katex .katex-sizing.reset-size11.size7{font-size:.4823151125em}.katex .fontsize-ensurer.reset-size11.size8,.katex .katex-sizing.reset-size11.size8{font-size:.578778135em}.katex .fontsize-ensurer.reset-size11.size9,.katex .katex-sizing.reset-size11.size9{font-size:.6945337621em}.katex .fontsize-ensurer.reset-size11.size10,.katex .katex-sizing.reset-size11.size10{font-size:.8336012862em}.katex .fontsize-ensurer.reset-size11.size11,.katex .katex-sizing.reset-size11.size11{font-size:1em}.katex .delimsizing.size1{font-family:KaTeX_Size1}.katex .delimsizing.size2{font-family:KaTeX_Size2}.katex .delimsizing.size3{font-family:KaTeX_Size3}.katex .delimsizing.size4{font-family:KaTeX_Size4}.katex .delimsizing.mult .delim-size1>span{font-family:KaTeX_Size1}.katex .delimsizing.mult .delim-size4>span{font-family:KaTeX_Size4}.katex .nulldelimiter{display:inline-block;width:.12em}.katex .delimcenter,.katex .op-symbol{position:relative}.katex .op-symbol.small-op{font-family:KaTeX_Size1}.katex .op-symbol.large-op{font-family:KaTeX_Size2}.katex .katex-accent>.vlist-t,.katex .op-limits>.vlist-t{text-align:center}.katex .katex-accent .accent-body{position:relative}.katex .katex-accent .accent-body:not(.accent-full){width:0}.katex .katex-overlay{display:block}.katex .mtable .vertical-separator{display:inline-block;min-width:1px}.katex .mtable .arraycolsep{display:inline-block}.katex .mtable .col-align-c>.vlist-t{text-align:center}.katex .mtable .col-align-l>.vlist-t{text-align:left}.katex .mtable .col-align-r>.vlist-t{text-align:right}.katex .svg-align{text-align:left}.katex svg{fill:currentColor;stroke:currentColor;display:block;height:inherit;position:absolute;width:100%}.katex svg path{stroke:none}.katex svg{fill-rule:nonzero;fill-opacity:1;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1}.katex img{border-style:none;max-height:none;max-width:none;min-height:0;min-width:0}.katex .katex-stretchy{display:block;overflow:hidden;position:relative;width:100%}.katex .katex-stretchy:after,.katex .katex-stretchy:before{content:\\\"\\\"}.katex .hide-tail{overflow:hidden;position:relative;width:100%}.katex .halfarrow-left{left:0;overflow:hidden;position:absolute;width:50.2%}.katex .halfarrow-right{overflow:hidden;position:absolute;right:0;width:50.2%}.katex .brace-left{left:0;overflow:hidden;position:absolute;width:25.1%}.katex .brace-center{left:25%;overflow:hidden;position:absolute;width:50%}.katex .brace-right{overflow:hidden;position:absolute;right:0;width:25.1%}.katex .x-arrow-pad{padding:0 .5em}.katex .cd-arrow-pad{padding:0 .55556em 0 .27778em}.katex .mover,.katex .munder,.katex .x-arrow{text-align:center}.katex .boxpad{padding:0 .3em}.katex .fbox,.katex .fcolorbox{border:.04em solid;box-sizing:border-box}.katex .cancel-pad{padding:0 .2em}.katex .cancel-lap{margin-left:-.2em;margin-right:-.2em}.katex .katex-sout{border-bottom-style:solid;border-bottom-width:.08em}.katex .angl{border-right:.049em solid;border-top:.049em solid;box-sizing:border-box;margin-right:.03889em}.katex .anglpad{padding:0 .03889em}.katex .reflectbox{display:inline-block;transform:scaleX(-1)}.katex .eqn-num:before{content:\\\"(\\\" counter(katexEqnNo) \\\")\\\";counter-increment:katexEqnNo}.katex .mml-eqn-num:before{content:\\\"(\\\" counter(mmlEqnNo) \\\")\\\";counter-increment:mmlEqnNo}.katex .mtr-glue{width:50%}.katex .cd-vert-arrow{display:inline-block;position:relative}.katex .cd-label-left{display:inline-block;position:absolute;right:calc(50% + .3em);text-align:left}.katex .cd-label-right{display:inline-block;left:calc(50% + .3em);position:absolute;text-align:right}.katex-display{display:block;margin:1em 0;text-align:center}.katex-display>.katex{display:block;text-align:center;white-space:nowrap}.katex-display>.katex>.katex-html{display:block;position:relative}.katex-display>.katex>.katex-html>.katex-tag{position:absolute;right:0}.katex-display.leqno>.katex>.katex-html>.katex-tag{left:0;right:auto}.katex-display.fleqn>.katex{padding-left:2em;text-align:left}body{counter-reset:katexEqnNo mmlEqnNo}\\n\";\n","/**\n * @ai-matrx/print/certificate — the certificate / acknowledgment page, and the\n * workbook composer that ends on one.\n *\n * ── ONE PRIMITIVE, TWO VERTICALS ────────────────────────────────────────────\n * PRINT-EDUCATION-RESEARCH.md §\"What the future-builders do\", finding 6:\n * the HR-handbook world (Blissbook / AirMason) builds the SAME primitives as\n * the test-prep world — cover → TOC → sections → signature page, where the\n * HR *acknowledgment/signature page* and the course *certificate page* are the\n * SAME printable unit wearing different words. So they are ONE printer here:\n * `formal` and `modern-minimal` are the education/course faces, and\n * `acknowledgment` is the HR twin (portrait, body text, \"I acknowledge…\",\n * signature and date rules). Build once, share across verticals — that is the\n * finding, implemented.\n *\n * Ornamentation is PURE CSS (nested/double borders + corner flourishes built\n * from border tricks). No images, no fonts to load, no external anything: a\n * certificate must print identically from a print window with no network.\n *\n * Import-inert: `window` is touched only inside `print`/`printWorkbook`.\n * `composeWorkbookPages` is pure and safe to call on a server.\n */\n\nimport { openPrintWindow, type BlockPrinter, type PrintOutcome, type PrintSettings } from \"./core\";\nimport { escapeHtml } from \"@ai-matrx/kit/html-escape\";\nimport { printBooklet } from \"./booklet\";\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface SignatureLine {\n /** Printed under the rule, above the role. Omitted = a blank rule to sign. */\n name?: string;\n role: string;\n}\n\nexport interface CertificateData {\n recipientName: string;\n title: string;\n subtitle?: string;\n body?: string;\n date?: string;\n signatures?: SignatureLine[];\n}\n\nexport type CertificateVariant = \"formal\" | \"modern-minimal\" | \"acknowledgment\";\n\n// ─── Data extraction ─────────────────────────────────────────────────────────\n\nfunction asString(value: unknown): string | undefined {\n return typeof value === \"string\" && value.trim().length > 0\n ? value.trim()\n : undefined;\n}\n\nfunction parseSignature(raw: unknown): SignatureLine | null {\n if (!raw || typeof raw !== \"object\") return null;\n const obj = raw as Record<string, unknown>;\n const role = asString(obj[\"role\"]);\n if (role === undefined) return null;\n const name = asString(obj[\"name\"]);\n return name === undefined ? { role } : { name, role };\n}\n\n/** Tolerant reader for host data — always yields a printable certificate. */\nexport function extractCertificate(data: unknown): CertificateData {\n if (!data || typeof data !== \"object\") {\n return { recipientName: \"\", title: \"Certificate\" };\n }\n const obj = data as Record<string, unknown>;\n const cert: CertificateData = {\n recipientName: asString(obj[\"recipientName\"]) ?? \"\",\n title: asString(obj[\"title\"]) ?? \"Certificate\",\n };\n const subtitle = asString(obj[\"subtitle\"]);\n if (subtitle !== undefined) cert.subtitle = subtitle;\n const body = asString(obj[\"body\"]);\n if (body !== undefined) cert.body = body;\n const date = asString(obj[\"date\"]);\n if (date !== undefined) cert.date = date;\n const rawSignatures = obj[\"signatures\"];\n if (Array.isArray(rawSignatures)) {\n const signatures = rawSignatures\n .map(parseSignature)\n .filter((s): s is SignatureLine => s !== null);\n if (signatures.length > 0) cert.signatures = signatures;\n }\n return cert;\n}\n\n// ─── Styles ──────────────────────────────────────────────────────────────────\n\nconst CERT_RESET = `\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n html, body {\n background: #fff;\n color: #111827;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;\n }\n .screen-only { padding: 14px 20px; border-bottom: 1px solid #e2e8f0; display: flex; gap: 10px; }\n .cert-btn {\n padding: 8px 20px; border-radius: 8px; font-size: 13px; font-family: inherit;\n font-weight: 600; cursor: pointer; border: none;\n }\n .cert-btn-primary { background: #2563eb; color: #fff; }\n .cert-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }\n @media print { .screen-only { display: none !important; } }\n\n .cert-signatures {\n display: flex;\n justify-content: center;\n gap: 0.8in;\n margin-top: 0.5in;\n }\n .cert-sig { min-width: 2.2in; text-align: center; }\n .cert-sig-rule {\n border-bottom: 1.5px solid #111827;\n height: 0.42in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-sig-name { font-size: 11pt; font-weight: 600; margin-top: 4px; }\n .cert-sig-role {\n font-size: 8.5pt;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: #4b5563;\n margin-top: 2px;\n }\n`;\n\n/**\n * The ornamental frame: a double border (outer heavy rule + inner hairline)\n * plus four corner flourishes drawn from two borders each — a pure-CSS\n * engraving that never depends on an image loading.\n */\nconst FORMAL_STYLES = `\n ${CERT_RESET}\n @page { size: 11in 8.5in; margin: 0; }\n\n .cert-page {\n width: 11in;\n height: 8.5in;\n padding: 0.45in;\n position: relative;\n break-after: page;\n page-break-after: always;\n }\n .cert-page:last-of-type { break-after: auto; page-break-after: auto; }\n\n .cert-frame {\n position: relative;\n width: 100%;\n height: 100%;\n border: 6px double #7c5c1e;\n padding: 0.28in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-frame-inner {\n width: 100%;\n height: 100%;\n border: 1px solid #b08b3f;\n padding: 0.5in 0.7in;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n text-align: center;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n /* Corner flourishes — two borders per pseudo-element, rotated by placement. */\n .cert-corner {\n position: absolute;\n width: 0.55in;\n height: 0.55in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-corner-tl { top: 0.1in; left: 0.1in; border-top: 3px solid #b08b3f; border-left: 3px solid #b08b3f; }\n .cert-corner-tr { top: 0.1in; right: 0.1in; border-top: 3px solid #b08b3f; border-right: 3px solid #b08b3f; }\n .cert-corner-bl { bottom: 0.1in; left: 0.1in; border-bottom: 3px solid #b08b3f; border-left: 3px solid #b08b3f; }\n .cert-corner-br { bottom: 0.1in; right: 0.1in; border-bottom: 3px solid #b08b3f; border-right: 3px solid #b08b3f; }\n\n .cert-eyebrow {\n font-size: 11pt;\n letter-spacing: 0.34em;\n text-transform: uppercase;\n color: #7c5c1e;\n }\n .cert-title { font-size: 30pt; font-weight: 700; margin-top: 0.1in; letter-spacing: 0.02em; }\n .cert-subtitle { font-size: 12pt; color: #4b5563; margin-top: 0.08in; }\n .cert-presented { font-size: 10.5pt; color: #4b5563; margin-top: 0.32in; letter-spacing: 0.16em; text-transform: uppercase; }\n .cert-recipient {\n font-size: 34pt;\n font-weight: 400;\n font-family: 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif;\n margin-top: 0.06in;\n padding-bottom: 0.08in;\n border-bottom: 1px solid #b08b3f;\n min-width: 5in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-body { font-size: 11pt; line-height: 1.6; max-width: 6.6in; margin-top: 0.24in; color: #1f2937; }\n .cert-date { font-size: 10pt; color: #4b5563; margin-top: 0.24in; }\n`;\n\nconst MODERN_STYLES = `\n ${CERT_RESET}\n @page { size: 11in 8.5in; margin: 0; }\n\n .cert-page {\n width: 11in;\n height: 8.5in;\n padding: 0.9in 1.1in;\n display: flex;\n flex-direction: column;\n justify-content: center;\n break-after: page;\n page-break-after: always;\n }\n .cert-page:last-of-type { break-after: auto; page-break-after: auto; }\n\n .cert-rule-top {\n width: 1.6in;\n border-top: 4px solid #111827;\n margin-bottom: 0.3in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-eyebrow {\n font-size: 9.5pt;\n letter-spacing: 0.3em;\n text-transform: uppercase;\n color: #6b7280;\n }\n .cert-title { font-size: 22pt; font-weight: 600; margin-top: 0.06in; }\n .cert-subtitle { font-size: 11pt; color: #6b7280; margin-top: 0.05in; }\n .cert-recipient { font-size: 40pt; font-weight: 700; letter-spacing: -0.01em; margin-top: 0.3in; }\n .cert-body { font-size: 11pt; line-height: 1.65; max-width: 6.2in; margin-top: 0.2in; color: #374151; }\n .cert-date { font-size: 10pt; color: #6b7280; margin-top: 0.24in; }\n .cert-signatures { justify-content: flex-start; gap: 0.7in; }\n .cert-sig { text-align: left; }\n`;\n\nconst ACK_STYLES = `\n ${CERT_RESET}\n @page { size: 8.5in 11in; margin: 0.75in; }\n\n .cert-page {\n break-after: page;\n page-break-after: always;\n }\n .cert-page:last-of-type { break-after: auto; page-break-after: auto; }\n\n .cert-title { font-size: 17pt; font-weight: 700; }\n .cert-subtitle { font-size: 11pt; color: #4b5563; margin-top: 3px; }\n .cert-body { font-size: 11pt; line-height: 1.7; margin-top: 0.28in; color: #1f2937; }\n .cert-ack-statement {\n margin-top: 0.3in;\n padding: 0.16in 0.2in;\n border: 1.5px solid #111827;\n font-size: 11pt;\n line-height: 1.65;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-ack-fields { margin-top: 0.4in; display: flex; gap: 0.4in; }\n .cert-ack-field { flex: 1; }\n .cert-ack-field-date { flex: 0 0 2in; }\n .cert-ack-rule {\n border-bottom: 1.5px solid #111827;\n height: 0.4in;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .cert-ack-label {\n font-size: 8.5pt;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: #4b5563;\n margin-top: 3px;\n }\n .cert-signatures { justify-content: flex-start; gap: 0.6in; }\n .cert-sig { text-align: left; }\n`;\n\n// ─── Rendering ───────────────────────────────────────────────────────────────\n\nfunction renderSignatures(signatures: readonly SignatureLine[]): string {\n if (signatures.length === 0) return \"\";\n const lines = signatures\n .map(\n (sig) => `<div class=\"cert-sig\">\n <div class=\"cert-sig-rule\"></div>\n ${sig.name ? `<div class=\"cert-sig-name\">${escapeHtml(sig.name)}</div>` : \"\"}\n <div class=\"cert-sig-role\">${escapeHtml(sig.role)}</div>\n </div>`,\n )\n .join(\"\");\n return `<div class=\"cert-signatures\">${lines}</div>`;\n}\n\nfunction renderFormal(cert: CertificateData): string {\n return `<div class=\"cert-page cert-formal\">\n <div class=\"cert-frame\">\n <span class=\"cert-corner cert-corner-tl\"></span>\n <span class=\"cert-corner cert-corner-tr\"></span>\n <span class=\"cert-corner cert-corner-bl\"></span>\n <span class=\"cert-corner cert-corner-br\"></span>\n <div class=\"cert-frame-inner\">\n <div class=\"cert-eyebrow\">Certificate</div>\n <div class=\"cert-title\">${escapeHtml(cert.title)}</div>\n ${cert.subtitle ? `<div class=\"cert-subtitle\">${escapeHtml(cert.subtitle)}</div>` : \"\"}\n <div class=\"cert-presented\">This certifies that</div>\n <div class=\"cert-recipient\">${escapeHtml(cert.recipientName)}</div>\n ${cert.body ? `<div class=\"cert-body\">${escapeHtml(cert.body)}</div>` : \"\"}\n ${cert.date ? `<div class=\"cert-date\">${escapeHtml(cert.date)}</div>` : \"\"}\n ${renderSignatures(cert.signatures ?? [])}\n </div>\n </div>\n</div>`;\n}\n\nfunction renderModernMinimal(cert: CertificateData): string {\n return `<div class=\"cert-page cert-modern\">\n <div class=\"cert-rule-top\"></div>\n <div class=\"cert-eyebrow\">Certificate of completion</div>\n <div class=\"cert-title\">${escapeHtml(cert.title)}</div>\n ${cert.subtitle ? `<div class=\"cert-subtitle\">${escapeHtml(cert.subtitle)}</div>` : \"\"}\n <div class=\"cert-recipient\">${escapeHtml(cert.recipientName)}</div>\n ${cert.body ? `<div class=\"cert-body\">${escapeHtml(cert.body)}</div>` : \"\"}\n ${cert.date ? `<div class=\"cert-date\">${escapeHtml(cert.date)}</div>` : \"\"}\n ${renderSignatures(cert.signatures ?? [])}\n</div>`;\n}\n\n/** The default acknowledgment sentence when the data supplies no body text. */\nconst ACK_STATEMENT =\n \"I acknowledge that I have received, read and understand this document, and I agree to comply with its contents.\";\n\nfunction renderAcknowledgment(cert: CertificateData): string {\n return `<div class=\"cert-page cert-acknowledgment\">\n <div class=\"cert-title\">${escapeHtml(cert.title)}</div>\n ${cert.subtitle ? `<div class=\"cert-subtitle\">${escapeHtml(cert.subtitle)}</div>` : \"\"}\n ${cert.body ? `<div class=\"cert-body\">${escapeHtml(cert.body)}</div>` : \"\"}\n <div class=\"cert-ack-statement\">${escapeHtml(ACK_STATEMENT)}</div>\n <div class=\"cert-ack-fields\">\n <div class=\"cert-ack-field\">\n <div class=\"cert-ack-rule\"></div>\n <div class=\"cert-ack-label\">Printed name${cert.recipientName ? ` — ${escapeHtml(cert.recipientName)}` : \"\"}</div>\n </div>\n <div class=\"cert-ack-field\">\n <div class=\"cert-ack-rule\"></div>\n <div class=\"cert-ack-label\">Signature</div>\n </div>\n <div class=\"cert-ack-field cert-ack-field-date\">\n <div class=\"cert-ack-rule\"></div>\n <div class=\"cert-ack-label\">Date${cert.date ? ` — ${escapeHtml(cert.date)}` : \"\"}</div>\n </div>\n </div>\n ${renderSignatures(cert.signatures ?? [])}\n</div>`;\n}\n\nfunction stylesFor(variant: CertificateVariant): string {\n switch (variant) {\n case \"modern-minimal\":\n return MODERN_STYLES;\n case \"acknowledgment\":\n return ACK_STYLES;\n case \"formal\":\n default:\n return FORMAL_STYLES;\n }\n}\n\n/** Render one certificate page fragment — the unit the workbook composer reuses. */\nexport function renderCertificatePage(\n cert: CertificateData,\n variant: CertificateVariant = \"formal\",\n): string {\n switch (variant) {\n case \"modern-minimal\":\n return renderModernMinimal(cert);\n case \"acknowledgment\":\n return renderAcknowledgment(cert);\n case \"formal\":\n default:\n return renderFormal(cert);\n }\n}\n\nfunction buildCertificateDocument(\n cert: CertificateData,\n variant: CertificateVariant,\n): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <title>${escapeHtml(cert.title)}</title>\n <style>\n${stylesFor(variant)}\n </style>\n</head>\n<body>\n <div class=\"screen-only\">\n <button class=\"cert-btn cert-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"cert-btn cert-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n${renderCertificatePage(cert, variant)}\n</body>\n</html>`;\n}\n\n/**\n * The certificate printer — and, in its `acknowledgment` variant, the HR\n * signature/acknowledgment page. They are ONE primitive on purpose\n * (PRINT-EDUCATION-RESEARCH.md §\"What the future-builders do\", finding 6:\n * the HR-handbook builders assemble cover → TOC → sections → signature page,\n * which is the same spine as a course workbook ending in a certificate).\n * Ornamentation is pure CSS; nothing here loads an image or a webfont.\n */\nexport const certificatePrinter: BlockPrinter = {\n label: \"Print certificate\",\n variants: [\n {\n id: \"formal\",\n label: \"Formal (ornamental border)\",\n description:\n \"Landscape letter with a double gold rule and corner flourishes — the classic course/award certificate. Pure CSS, no images\",\n },\n {\n id: \"modern-minimal\",\n label: \"Modern minimal\",\n description:\n \"Landscape letter, left-aligned, no ornament — a large recipient name over a single rule\",\n },\n {\n id: \"acknowledgment\",\n label: \"Acknowledgment / signature page (HR)\",\n description:\n \"Portrait letter: policy body text, an \\\"I acknowledge…\\\" statement, and printed-name / signature / date rules. The HR twin of the certificate page\",\n },\n ],\n\n print(\n data: unknown,\n variantId: string = \"formal\",\n _settings?: PrintSettings,\n ): PrintOutcome {\n const cert = extractCertificate(data);\n const variant = (\n [\"formal\", \"modern-minimal\", \"acknowledgment\"].includes(variantId)\n ? variantId\n : \"formal\"\n ) as CertificateVariant;\n return openPrintWindow(\n buildCertificateDocument(cert, variant),\n cert.title,\n \"width=1100,height=760,scrollbars=yes\",\n );\n },\n};\n\n// ─── Workbook composer ───────────────────────────────────────────────────────\n\nexport interface WorkbookSection {\n title: string;\n /** Already-rendered page fragments — TRUSTED HTML, injected verbatim. */\n pagesHtml: readonly string[];\n}\n\nexport interface WorkbookSpec {\n title: string;\n subtitle?: string;\n sections: readonly WorkbookSection[];\n includeToc?: boolean;\n finalPage?:\n | { kind: \"certificate\"; data: CertificateData }\n | { kind: \"html\"; html: string };\n}\n\nconst WORKBOOK_STYLES = `\n .wb-cover {\n height: 100%;\n display: flex;\n flex-direction: column;\n justify-content: center;\n align-items: center;\n text-align: center;\n padding: 0.9in 0.7in;\n }\n .wb-cover-title { font-size: 26pt; font-weight: 700; line-height: 1.2; }\n .wb-cover-subtitle { font-size: 12pt; color: #4b5563; margin-top: 0.16in; }\n .wb-cover-rule {\n width: 1.4in;\n border-top: 3px solid #111827;\n margin: 0.28in 0;\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .wb-toc { padding: 0.7in 0.7in; }\n .wb-toc h2 { font-size: 15pt; font-weight: 700; margin-bottom: 0.2in; }\n .wb-toc-row {\n display: flex;\n align-items: baseline;\n gap: 6px;\n font-size: 11pt;\n margin-bottom: 8px;\n }\n .wb-toc-label { white-space: nowrap; }\n .wb-toc-dots {\n flex: 1;\n border-bottom: 1px dotted #9ca3af;\n transform: translateY(-3px);\n -webkit-print-color-adjust: exact;\n print-color-adjust: exact;\n }\n .wb-toc-page { font-variant-numeric: tabular-nums; font-weight: 600; }\n .wb-section-title-page {\n height: 100%;\n display: flex;\n flex-direction: column;\n justify-content: center;\n padding: 0.9in 0.8in;\n }\n .wb-section-eyebrow {\n font-size: 9pt;\n letter-spacing: 0.28em;\n text-transform: uppercase;\n color: #6b7280;\n }\n .wb-section-name { font-size: 22pt; font-weight: 700; margin-top: 0.08in; }\n`;\n\n/** Page-break scaffolding for the SEQUENTIAL (non-booklet) workbook lane. */\nconst WORKBOOK_SEQUENTIAL_STYLES = `\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n @page { size: 8.5in 11in; margin: 0; }\n html, body {\n background: #fff;\n color: #111827;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;\n }\n .wb-page {\n width: 8.5in;\n height: 11in;\n overflow: hidden;\n position: relative;\n break-after: page;\n page-break-after: always;\n }\n .wb-page:last-of-type { break-after: auto; page-break-after: auto; }\n .screen-only { padding: 14px 20px; border-bottom: 1px solid #e2e8f0; display: flex; gap: 10px; }\n .wb-btn {\n padding: 8px 20px; border-radius: 8px; font-size: 13px; font-family: inherit;\n font-weight: 600; cursor: pointer; border: none;\n }\n .wb-btn-primary { background: #2563eb; color: #fff; }\n .wb-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }\n @media print { .screen-only { display: none !important; } }\n${WORKBOOK_STYLES}\n${CERT_RESET_FOR_WORKBOOK()}\n`;\n\n/**\n * The certificate's signature/frame rules, reused when a certificate rides\n * INSIDE a workbook. The certificate's own `@page` size is deliberately NOT\n * included — a workbook is portrait, and a landscape @page in the middle of it\n * would repaginate the whole document.\n */\nfunction CERT_RESET_FOR_WORKBOOK(): string {\n return `\n .cert-page { width: 100%; height: 100%; padding: 0.5in; break-after: auto; page-break-after: auto; }\n ${CERT_RESET.split(\"@media print\")[0] ?? \"\"}\n .cert-frame { position: relative; width: 100%; height: 100%; border: 6px double #7c5c1e; padding: 0.24in; -webkit-print-color-adjust: exact; print-color-adjust: exact; }\n .cert-frame-inner { width: 100%; height: 100%; border: 1px solid #b08b3f; padding: 0.4in; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }\n .cert-corner { position: absolute; width: 0.5in; height: 0.5in; }\n .cert-corner-tl { top: 0.1in; left: 0.1in; border-top: 3px solid #b08b3f; border-left: 3px solid #b08b3f; }\n .cert-corner-tr { top: 0.1in; right: 0.1in; border-top: 3px solid #b08b3f; border-right: 3px solid #b08b3f; }\n .cert-corner-bl { bottom: 0.1in; left: 0.1in; border-bottom: 3px solid #b08b3f; border-left: 3px solid #b08b3f; }\n .cert-corner-br { bottom: 0.1in; right: 0.1in; border-bottom: 3px solid #b08b3f; border-right: 3px solid #b08b3f; }\n .cert-eyebrow { font-size: 10pt; letter-spacing: 0.3em; text-transform: uppercase; color: #7c5c1e; }\n .cert-title { font-size: 22pt; font-weight: 700; margin-top: 0.08in; }\n .cert-subtitle { font-size: 11pt; color: #4b5563; margin-top: 0.06in; }\n .cert-presented { font-size: 10pt; color: #4b5563; margin-top: 0.24in; letter-spacing: 0.16em; text-transform: uppercase; }\n .cert-recipient { font-size: 26pt; font-family: 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif; margin-top: 0.06in; padding-bottom: 0.06in; border-bottom: 1px solid #b08b3f; min-width: 3.4in; }\n .cert-body { font-size: 10.5pt; line-height: 1.6; margin-top: 0.2in; }\n .cert-date { font-size: 10pt; color: #4b5563; margin-top: 0.2in; }\n`;\n}\n\nfunction renderCoverPage(spec: WorkbookSpec): string {\n return `<div class=\"wb-cover\">\n <div class=\"wb-cover-title\">${escapeHtml(spec.title)}</div>\n <div class=\"wb-cover-rule\"></div>\n ${spec.subtitle ? `<div class=\"wb-cover-subtitle\">${escapeHtml(spec.subtitle)}</div>` : \"\"}\n</div>`;\n}\n\nfunction renderSectionTitlePage(section: WorkbookSection, index: number): string {\n return `<div class=\"wb-section-title-page\" data-section=\"${index + 1}\">\n <div class=\"wb-section-eyebrow\">Section ${index + 1}</div>\n <div class=\"wb-section-name\">${escapeHtml(section.title)}</div>\n</div>`;\n}\n\nfunction renderTocPage(\n spec: WorkbookSpec,\n entries: readonly { title: string; page: number }[],\n): string {\n const rows = entries\n .map(\n (entry) => `<div class=\"wb-toc-row\" data-toc-page=\"${entry.page}\">\n <span class=\"wb-toc-label\">${escapeHtml(entry.title)}</span>\n <span class=\"wb-toc-dots\"></span>\n <span class=\"wb-toc-page\">${entry.page}</span>\n </div>`,\n )\n .join(\"\");\n return `<div class=\"wb-toc\">\n <h2>${escapeHtml(spec.title)} — Contents</h2>\n ${rows}\n</div>`;\n}\n\n/**\n * Compose a workbook into per-page HTML fragments, in reading order:\n *\n * cover → [table of contents] → (section title page + its content pages)* → [final page]\n *\n * PURE: no `window`, no DOM, no mutable module state — safe on a server, and\n * the exact array it returns is what the booklet lane hands to `printBooklet`.\n *\n * The TOC page numbers are COMPUTED from the same layout this function emits:\n * page 1 is the cover, the TOC (when present) is page 2, and each section's\n * number is the 1-based index of its own title page. Because the TOC itself\n * occupies a page, its presence is folded into the offset before any section\n * number is calculated — the classic off-by-one in every hand-built TOC.\n */\nexport function composeWorkbookPages(spec: WorkbookSpec): string[] {\n const hasToc = spec.includeToc === true;\n // Cover is page 1; the TOC, when present, is page 2. Sections start after.\n const firstSectionPage = hasToc ? 3 : 2;\n\n const entries: { title: string; page: number }[] = [];\n let cursor = firstSectionPage;\n for (const section of spec.sections) {\n entries.push({ title: section.title, page: cursor });\n cursor += 1 + section.pagesHtml.length; // title page + content pages\n }\n\n const pages: string[] = [renderCoverPage(spec)];\n if (hasToc) pages.push(renderTocPage(spec, entries));\n spec.sections.forEach((section, i) => {\n pages.push(renderSectionTitlePage(section, i));\n for (const pageHtml of section.pagesHtml) pages.push(pageHtml);\n });\n\n if (spec.finalPage) {\n pages.push(\n spec.finalPage.kind === \"certificate\"\n ? renderCertificatePage(spec.finalPage.data, \"formal\")\n : spec.finalPage.html,\n );\n }\n return pages;\n}\n\nexport interface WorkbookPrintOptions {\n /** true = saddle-stitch imposition via ./booklet; false/omitted = sequential. */\n booklet?: boolean;\n /** Extra CSS appended after the workbook styles — style the caller's pages. */\n pageStyles?: string;\n}\n\n/**\n * Print a workbook. Two lanes, one composer:\n * - `booklet: true` → the composed pages go straight to `printBooklet`\n * (2-up landscape imposition, fold-and-staple).\n * - otherwise → one portrait page per composed fragment, each with a\n * page break after it.\n */\nexport function printWorkbook(\n spec: WorkbookSpec,\n opts?: WorkbookPrintOptions,\n): PrintOutcome {\n const pages = composeWorkbookPages(spec);\n const extra = opts?.pageStyles ?? \"\";\n\n if (opts?.booklet === true) {\n return printBooklet(pages, {\n title: spec.title,\n pageStyles: `${WORKBOOK_STYLES}\\n${CERT_RESET_FOR_WORKBOOK()}\\n${extra}`,\n });\n }\n\n const body = pages\n .map(\n (html, i) => `<div class=\"wb-page\" data-page=\"${i + 1}\">${html}</div>`,\n )\n .join(\"\\n\");\n\n const doc = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <title>${escapeHtml(spec.title)}</title>\n <style>\n${WORKBOOK_SEQUENTIAL_STYLES}\n${extra}\n </style>\n</head>\n<body>\n <div class=\"screen-only\">\n <button class=\"wb-btn wb-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"wb-btn wb-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n${body}\n</body>\n</html>`;\n\n return openPrintWindow(doc, spec.title, \"width=920,height=760,scrollbars=yes\");\n}\n","/**\n * @ai-matrx/print/booklet — saddle-stitch booklet imposition.\n *\n * The education white space: no test-prep / worksheet tool offers \"turn this\n * into a fold-and-staple booklet\". This module is the whole capability —\n * the imposition math, the size guidance, and a 2-up landscape print lane\n * composed over ./core.\n *\n * Zero dependencies: pure arithmetic plus string composition. Import-inert —\n * `window` is touched only inside printBooklet, at call time.\n *\n * ── THE MATH ────────────────────────────────────────────────────────────────\n * A saddle-stitch booklet is a stack of sheets folded in half together and\n * stapled through the spine. Every sheet carries FOUR logical pages (two per\n * side), so the page count is padded up to a multiple of 4 with blanks.\n *\n * For sheet i (1-indexed, i = 1..N/4) of an N-page (padded) booklet:\n * front spread = [N − 2i + 2, 2i − 1] (left, right)\n * back spread = [2i, N − 2i + 1] (left, right)\n *\n * Worked anchor, N = 8:\n * sheet 1 → front [8, 1] back [2, 7]\n * sheet 2 → front [6, 3] back [4, 5]\n * Every spread's two page numbers sum to N + 1 — the invariant that makes the\n * fold work, and the cheapest way to check an imposition is right.\n *\n * ── THE DUPLEX TRAP ─────────────────────────────────────────────────────────\n * These spreads are 2-up LANDSCAPE. Ordinary duplex printing flips on the LONG\n * edge, which for a landscape sheet means the back side lands upside-down\n * relative to the front — the booklet folds into gibberish. There are exactly\n * two fixes: instruct the human to select short-edge (tumble) duplex, or\n * pre-rotate every back side 180°. A print-window lane cannot pre-rotate\n * reliably (browsers differ on how `@page`/transform interact with duplex), so\n * THIS lane instructs. `BookletImposition.requiresShortEdgeDuplex` exposes the\n * trap in the type system so a future PDF lane — which owns its own page\n * transforms — can take the pre-rotate branch instead of the instruction.\n */\n\nimport { openPrintWindow, type PrintOutcome } from \"./core\";\nimport { escapeHtml } from \"@ai-matrx/kit/html-escape\";\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/**\n * One physical sheet of paper. Each tuple is [left, right] as the reader sees\n * that side laid flat, in 1-based logical page numbers.\n * `0` means a blank pad page (a slot past the end of the supplied content).\n */\nexport interface BookletSheet {\n front: [number, number];\n back: [number, number];\n}\n\nexport interface BookletImposition {\n /** Supplied page count rounded UP to the next multiple of 4. */\n paddedPageCount: number;\n /** Sheets in print order: sheet 1 outermost, last sheet at the fold. */\n sheets: BookletSheet[];\n /**\n * true when ordinary long-edge duplex would flip the backs — consumer must\n * either instruct short-edge/tumble duplex or pre-rotate backs 180° (PDF\n * lanes). Always true for this 2-up landscape imposition.\n */\n requiresShortEdgeDuplex: true;\n}\n\nexport interface BookletPrintOptions {\n title?: string;\n /** CSS injected after the layout styles — style the caller's page content. */\n pageStyles?: string;\n}\n\n// ─── Imposition ──────────────────────────────────────────────────────────────\n\n/** Sheets carry four logical pages each; everything pads to this. */\nconst PAGES_PER_SHEET = 4;\n\n/**\n * Past roughly this many letter pages, a saddle-stitch booklet gets too thick\n * to fold cleanly and the creep (inner pages pushing out at the fore-edge)\n * becomes visible. Callers should offer \"print as a workbook instead\".\n */\nconst SADDLE_STITCH_MAX_PAGES = 48;\n\n/**\n * Impose `pageCount` logical pages onto saddle-stitch sheets.\n *\n * Padding is appended at the END of the logical page run, so pad slots always\n * carry the HIGHEST page numbers — which is why they surface as `0` in the\n * spreads nearest the front cover (N=1 → sheet 1 front [0, 1], back [0, 0]).\n *\n * @throws RangeError when pageCount is not a positive integer.\n */\nexport function imposeBooklet(pageCount: number): BookletImposition {\n if (!Number.isInteger(pageCount) || pageCount < 1) {\n throw new RangeError(\n `imposeBooklet: pageCount must be a positive integer, received ${String(pageCount)}`,\n );\n }\n\n const padded = Math.ceil(pageCount / PAGES_PER_SHEET) * PAGES_PER_SHEET;\n // A slot past the supplied content is a blank pad page, reported as 0.\n const slot = (n: number): number => (n > pageCount ? 0 : n);\n\n const sheets: BookletSheet[] = [];\n for (let i = 1; i <= padded / PAGES_PER_SHEET; i += 1) {\n sheets.push({\n front: [slot(padded - 2 * i + 2), slot(2 * i - 1)],\n back: [slot(2 * i), slot(padded - 2 * i + 1)],\n });\n }\n\n return { paddedPageCount: padded, sheets, requiresShortEdgeDuplex: true };\n}\n\n/**\n * Human warning when a booklet is too fat to saddle-stitch well, else null.\n * Measured on the PADDED count — that is what actually goes through the folder.\n */\nexport function bookletSizeWarning(pageCount: number): string | null {\n if (!Number.isInteger(pageCount) || pageCount < 1) return null;\n const padded = Math.ceil(pageCount / PAGES_PER_SHEET) * PAGES_PER_SHEET;\n if (padded <= SADDLE_STITCH_MAX_PAGES) return null;\n return (\n `This booklet is ${padded} pages (${padded / PAGES_PER_SHEET} sheets). ` +\n `Past about ${SADDLE_STITCH_MAX_PAGES} pages a saddle-stitch booklet gets too thick to fold flat ` +\n `and the inner pages creep out at the fore-edge. Consider printing it as a plain workbook instead, ` +\n `or splitting it into ${SADDLE_STITCH_MAX_PAGES}-page signatures.`\n );\n}\n\n// ─── Print lane ──────────────────────────────────────────────────────────────\n\nconst BOOKLET_STYLES = `\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n @page { size: 11in 8.5in; margin: 0; }\n\n html, body {\n background: #fff;\n color: #1a1a1a;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;\n }\n\n .bk-side {\n display: flex;\n flex-direction: row;\n width: 11in;\n height: 8.5in;\n page-break-after: always;\n break-after: page;\n overflow: hidden;\n }\n .bk-side:last-of-type { page-break-after: auto; break-after: auto; }\n\n .bk-page {\n width: 5.5in;\n height: 8.5in;\n overflow: hidden;\n position: relative;\n }\n .bk-blank { /* intentionally empty — a padded slot */ }\n\n .bk-banner {\n background: #fef3c7;\n border: 2px solid #f59e0b;\n border-radius: 8px;\n padding: 14px 18px;\n margin: 18px;\n font-size: 13px;\n line-height: 1.55;\n color: #78350f;\n max-width: 11in;\n }\n .bk-banner h2 { font-size: 15px; margin-bottom: 6px; }\n .bk-banner ol { margin: 6px 0 6px 20px; }\n .bk-banner strong { font-weight: 700; }\n .bk-banner .bk-size-warning {\n display: block;\n margin-top: 10px;\n padding-top: 10px;\n border-top: 1px solid #f59e0b;\n color: #7f1d1d;\n font-weight: 600;\n }\n .bk-actions { display: flex; gap: 10px; margin: 0 18px 18px; }\n .bk-btn {\n padding: 8px 20px; border-radius: 8px; font-size: 13px; font-family: inherit;\n font-weight: 600; cursor: pointer; border: none;\n }\n .bk-btn-primary { background: #2563eb; color: #fff; }\n .bk-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }\n\n @media print {\n .screen-only { display: none !important; }\n .bk-side { page-break-after: always; break-after: page; }\n }\n`;\n\nfunction renderBox(page: number, pagesHtml: readonly string[]): string {\n // Content fragments are TRUSTED HTML supplied by the caller — they are\n // already-rendered pages, so they are injected verbatim and NOT escaped.\n const html = page === 0 ? undefined : pagesHtml[page - 1];\n if (html === undefined) {\n return `<div class=\"bk-page bk-blank\" data-page=\"0\"></div>`;\n }\n return `<div class=\"bk-page\" data-page=\"${page}\">${html}</div>`;\n}\n\nfunction renderSide(spread: readonly [number, number], pagesHtml: readonly string[]): string {\n return `<section class=\"bk-side\">${renderBox(spread[0], pagesHtml)}${renderBox(spread[1], pagesHtml)}</section>`;\n}\n\nfunction renderBanner(imposition: BookletImposition, warning: string | null): string {\n const sheetCount = imposition.sheets.length;\n const warningHtml = warning\n ? `<span class=\"bk-size-warning\">${escapeHtml(warning)}</span>`\n : \"\";\n return `<div class=\"bk-banner screen-only\">\n <h2>Booklet print settings — set these exactly</h2>\n <ol>\n <li>Orientation: <strong>Landscape</strong></li>\n <li>Two-sided printing: <strong>ON</strong></li>\n <li>Flip on <strong>SHORT edge</strong> (also called \"tumble\" or \"short-edge binding\") — long-edge flip prints every back side upside-down</li>\n <li>Scale: <strong>100%</strong> (never \"fit to page\")</li>\n <li>Margins: <strong>None</strong></li>\n </ol>\n <p><strong>Print all pages, fold the whole stack in half, staple the spine.</strong>\n ${sheetCount} sheet${sheetCount === 1 ? \"\" : \"s\"} of paper, ${imposition.paddedPageCount} booklet pages.</p>\n ${warningHtml}\n </div>`;\n}\n\n/**\n * Compose the full booklet document.\n *\n * `pagesHtml[i]` is the already-rendered HTML for logical page i+1. Those\n * fragments are TRUSTED and injected verbatim (they are rendered pages, and\n * escaping them would print markup). Only the title is escaped.\n */\nexport function buildBookletDocument(\n pagesHtml: readonly string[],\n opts?: BookletPrintOptions,\n): string {\n const title = opts?.title ?? \"Booklet\";\n const pageStyles = opts?.pageStyles ?? \"\";\n const imposition = imposeBooklet(Math.max(1, pagesHtml.length));\n const warning = bookletSizeWarning(pagesHtml.length);\n\n // Sheet order: sheet1 front, sheet1 back, sheet2 front, ... — print the whole\n // run, then fold the stack as one.\n const sides = imposition.sheets\n .map((sheet) => renderSide(sheet.front, pagesHtml) + renderSide(sheet.back, pagesHtml))\n .join(\"\\n\");\n\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(title)}</title>\n <style>\n${BOOKLET_STYLES}\n${pageStyles}\n </style>\n</head>\n<body>\n${renderBanner(imposition, warning)}\n <div class=\"bk-actions screen-only\">\n <button class=\"bk-btn bk-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"bk-btn bk-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n${sides}\n</body>\n</html>`;\n}\n\n/** Build the booklet document and open it in a print window. */\nexport function printBooklet(\n pagesHtml: readonly string[],\n opts?: BookletPrintOptions,\n): PrintOutcome {\n const title = opts?.title ?? \"Booklet\";\n return openPrintWindow(buildBookletDocument(pagesHtml, opts), title);\n}\n"],"mappings":"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","names":["escapeHtml","escapeHtml","escapeHtml"]}
|
package/dist/core.cjs
CHANGED
|
@@ -23,6 +23,7 @@ __export(core_exports, {
|
|
|
23
23
|
buildFitTextScript: () => buildFitTextScript,
|
|
24
24
|
buildPrintDocument: () => buildPrintDocument,
|
|
25
25
|
getBlockPrinter: () => getBlockPrinter,
|
|
26
|
+
getPrintHost: () => getPrintHost,
|
|
26
27
|
listBlockPrinterKeys: () => listBlockPrinterKeys,
|
|
27
28
|
openPendingPrintWindow: () => openPendingPrintWindow,
|
|
28
29
|
openPrintWindow: () => openPrintWindow,
|
|
@@ -31,6 +32,7 @@ __export(core_exports, {
|
|
|
31
32
|
printHtmlContent: () => printHtmlContent,
|
|
32
33
|
registerBlockPrinter: () => registerBlockPrinter,
|
|
33
34
|
registerBlockPrinterLoader: () => registerBlockPrinterLoader,
|
|
35
|
+
setPrintHost: () => setPrintHost,
|
|
34
36
|
stripInteractiveChrome: () => stripInteractiveChrome,
|
|
35
37
|
whenBlockPrintersReady: () => whenBlockPrintersReady,
|
|
36
38
|
withMathStyles: () => withMathStyles
|
|
@@ -203,8 +205,30 @@ function withMathStyles(htmlDocument) {
|
|
|
203
205
|
const head = htmlDocument.search(/<\/head>/i);
|
|
204
206
|
return head >= 0 ? htmlDocument.slice(0, head) + style + htmlDocument.slice(head) : style + htmlDocument;
|
|
205
207
|
}
|
|
208
|
+
var PRINT_HOST = /* @__PURE__ */ Symbol.for("ai-matrx.print.host");
|
|
209
|
+
function setPrintHost(host) {
|
|
210
|
+
globalThis[PRINT_HOST] = host;
|
|
211
|
+
}
|
|
212
|
+
function getPrintHost() {
|
|
213
|
+
return globalThis[PRINT_HOST] ?? null;
|
|
214
|
+
}
|
|
215
|
+
function handToPrintHost(htmlDocument, title) {
|
|
216
|
+
const host = getPrintHost();
|
|
217
|
+
if (!host) return false;
|
|
218
|
+
try {
|
|
219
|
+
const done = host(htmlDocument, { title });
|
|
220
|
+
if (done && typeof done.catch === "function") {
|
|
221
|
+
done.catch((error) => console.error("[@ai-matrx/print] the host's print failed", error));
|
|
222
|
+
}
|
|
223
|
+
return true;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
console.error("[@ai-matrx/print] the host's print threw; using the print window instead", error);
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
206
229
|
function openPrintWindow(rawDocument, filename = "print", windowFeatures = "width=920,height=720,scrollbars=yes") {
|
|
207
230
|
const htmlDocument = withMathStyles(rawDocument);
|
|
231
|
+
if (handToPrintHost(htmlDocument, filename)) return "opened";
|
|
208
232
|
const win = (0, import_outbound.openOutbound)("", "_blank", windowFeatures);
|
|
209
233
|
if (!win) return openPrintWindowFallback(htmlDocument, filename);
|
|
210
234
|
win.document.open();
|
|
@@ -213,7 +237,7 @@ function openPrintWindow(rawDocument, filename = "print", windowFeatures = "widt
|
|
|
213
237
|
return "opened";
|
|
214
238
|
}
|
|
215
239
|
function openPendingPrintWindow(filename = "print", windowFeatures = "width=920,height=720,scrollbars=yes") {
|
|
216
|
-
const win = (0, import_outbound.openOutbound)("", "_blank", windowFeatures);
|
|
240
|
+
const win = getPrintHost() ? null : (0, import_outbound.openOutbound)("", "_blank", windowFeatures);
|
|
217
241
|
if (win) {
|
|
218
242
|
win.document.open();
|
|
219
243
|
win.document.write(
|
|
@@ -224,6 +248,7 @@ function openPendingPrintWindow(filename = "print", windowFeatures = "width=920,
|
|
|
224
248
|
return {
|
|
225
249
|
write(rawDocument) {
|
|
226
250
|
const htmlDocument = withMathStyles(rawDocument);
|
|
251
|
+
if (!win && handToPrintHost(htmlDocument, filename)) return "opened";
|
|
227
252
|
if (!win || win.closed) return openPrintWindowFallback(htmlDocument, filename);
|
|
228
253
|
win.document.open();
|
|
229
254
|
win.document.write(htmlDocument);
|