@ai-matrx/print 0.4.0 → 0.5.1

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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 — 2026-09-10
4
+
5
+ **Republished so `@ai-matrx/kit` floats instead of pinning.** 0.5.0's packed
6
+ manifest declared `"@ai-matrx/kit": "0.8.0"` — not a choice anyone made, but
7
+ what `pnpm pack` writes when it rewrites a `workspace:*` sibling spec. That pin
8
+ re-entered every consumer graph as a permanently stale second copy of kit that
9
+ no consumer command could move; only this republish could.
10
+
11
+ The class is closed at the mechanism (C15 / C27, THE TRANSITIVE PIN HAZARD):
12
+ the published manifest declares every `@ai-matrx/*` sibling as `"latest"` via
13
+ one shared pack step, `workspace:*` stays in source, and every package's
14
+ tarball canary now fails on a packed sibling spec that is not exactly
15
+ `"latest"`. No source change.
16
+
17
+ ### Consumer action
18
+
19
+ none — sibling specs now float.
20
+
21
+ ## 0.5.0 — 2026-09-07
22
+
23
+ **Consumer action (C28).** One export MOVED OUT of this package.
24
+
25
+ | was | now |
26
+ |---|---|
27
+ | `import { escapeHtml } from "@ai-matrx/print/core"` | `import { escapeHtml } from "@ai-matrx/kit/html-escape"` |
28
+
29
+ **The character set is now decided fleet-wide.** This package escaped
30
+ `& < > " '`; kit escapes the same five, so for THIS package's callers the
31
+ output is byte-identical. The change is fleet-wide: duplication census row 14
32
+ found NINE live copies of `escapeHtml` that **did not escape the same
33
+ characters** — three did `& < >` only, two added `"`, four added `'` (two as
34
+ `&#39;`, two as `&#039;`). A string safe through one was an HTML-attribute
35
+ break-out through another. There is now one decided set — all five, always, so
36
+ the result is safe as element text *and* inside a quoted attribute.
37
+
38
+ It lives in `@ai-matrx/kit/html-escape` rather than here because an HTML
39
+ escaper must not require this package's `jspdf` + `html2canvas` + `bwip-js`
40
+ graph to be installed. All eight internal print modules that used it now import
41
+ kit directly; nothing is re-exported from `./core`.
42
+
43
+ New dependency: `@ai-matrx/kit` (`latest`).
44
+
3
45
  ## 0.4.0 — 2026-08-31
4
46
 
5
47
  Wave 3 — the education artifact library, the certificate/workbook primitives,
package/dist/booklet.cjs CHANGED
@@ -28,9 +28,7 @@ __export(booklet_exports, {
28
28
  module.exports = __toCommonJS(booklet_exports);
29
29
 
30
30
  // src/core.ts
31
- function escapeHtml(str) {
32
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
33
- }
31
+ var import_html_escape = require("@ai-matrx/kit/html-escape");
34
32
  function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "width=920,height=720,scrollbars=yes") {
35
33
  const win = window.open("", "_blank", windowFeatures);
36
34
  if (!win) {
@@ -52,6 +50,7 @@ function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "wid
52
50
  }
53
51
 
54
52
  // src/booklet.ts
53
+ var import_html_escape2 = require("@ai-matrx/kit/html-escape");
55
54
  var PAGES_PER_SHEET = 4;
56
55
  var SADDLE_STITCH_MAX_PAGES = 48;
57
56
  function imposeBooklet(pageCount) {
@@ -154,7 +153,7 @@ function renderSide(spread, pagesHtml) {
154
153
  }
155
154
  function renderBanner(imposition, warning) {
156
155
  const sheetCount = imposition.sheets.length;
157
- const warningHtml = warning ? `<span class="bk-size-warning">${escapeHtml(warning)}</span>` : "";
156
+ const warningHtml = warning ? `<span class="bk-size-warning">${(0, import_html_escape2.escapeHtml)(warning)}</span>` : "";
158
157
  return `<div class="bk-banner screen-only">
159
158
  <h2>Booklet print settings \u2014 set these exactly</h2>
160
159
  <ol>
@@ -180,7 +179,7 @@ function buildBookletDocument(pagesHtml, opts) {
180
179
  <head>
181
180
  <meta charset="UTF-8">
182
181
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
183
- <title>${escapeHtml(title)}</title>
182
+ <title>${(0, import_html_escape2.escapeHtml)(title)}</title>
184
183
  <style>
185
184
  ${BOOKLET_STYLES}
186
185
  ${pageStyles}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/booklet.ts","../src/core.ts"],"sourcesContent":["/**\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 { escapeHtml, openPrintWindow, type PrintOutcome } from \"./core\";\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","/**\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// ─── 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// ─── 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\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\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 */\nexport function openPrintWindow(\n htmlDocument: string,\n filename = \"print\",\n windowFeatures = \"width=920,height=720,scrollbars=yes\",\n): PrintOutcome {\n const win = window.open(\"\", \"_blank\", windowFeatures);\n if (!win) {\n // Popup blocked — fall back to file download\n const blob = new Blob([htmlDocument], { type: \"text/html;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `${filename.replace(/\\s+/g, \"-\").toLowerCase()}.html`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke on a macrotask so the browser has started the blob fetch —\n // a synchronous revoke races the download in some engines.\n setTimeout(() => URL.revokeObjectURL(url), 0);\n return \"downloaded\";\n }\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n return \"opened\";\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\nexport { escapeHtml };\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/booklet.ts","../src/core.ts"],"sourcesContent":["/**\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","/**\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 { escapeHtml } from \"@ai-matrx/kit/html-escape\";\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// ─── 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 */\nexport function openPrintWindow(\n htmlDocument: string,\n filename = \"print\",\n windowFeatures = \"width=920,height=720,scrollbars=yes\",\n): PrintOutcome {\n const win = window.open(\"\", \"_blank\", windowFeatures);\n if (!win) {\n // Popup blocked — fall back to file download\n const blob = new Blob([htmlDocument], { type: \"text/html;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `${filename.replace(/\\s+/g, \"-\").toLowerCase()}.html`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke on a macrotask so the browser has started the blob fetch —\n // a synchronous revoke races the download in some engines.\n setTimeout(() => URL.revokeObjectURL(url), 0);\n return \"downloaded\";\n }\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n return \"opened\";\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"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACsBA,yBAA2B;AAkPpB,SAAS,gBACd,cACA,WAAW,SACX,iBAAiB,uCACH;AACd,QAAM,MAAM,OAAO,KAAK,IAAI,UAAU,cAAc;AACpD,MAAI,CAAC,KAAK;AAER,UAAM,OAAO,IAAI,KAAK,CAAC,YAAY,GAAG,EAAE,MAAM,0BAA0B,CAAC;AACzE,UAAM,MAAM,IAAI,gBAAgB,IAAI;AACpC,UAAM,IAAI,SAAS,cAAc,GAAG;AACpC,MAAE,OAAO;AACT,MAAE,WAAW,GAAG,SAAS,QAAQ,QAAQ,GAAG,EAAE,YAAY,CAAC;AAC3D,aAAS,KAAK,YAAY,CAAC;AAC3B,MAAE,MAAM;AACR,aAAS,KAAK,YAAY,CAAC;AAG3B,eAAW,MAAM,IAAI,gBAAgB,GAAG,GAAG,CAAC;AAC5C,WAAO;AAAA,EACT;AACA,MAAI,SAAS,KAAK;AAClB,MAAI,SAAS,MAAM,YAAY;AAC/B,MAAI,SAAS,MAAM;AACnB,SAAO;AACT;;;AD1PA,IAAAA,sBAA2B;AAoC3B,IAAM,kBAAkB;AAOxB,IAAM,0BAA0B;AAWzB,SAAS,cAAc,WAAsC;AAClE,MAAI,CAAC,OAAO,UAAU,SAAS,KAAK,YAAY,GAAG;AACjD,UAAM,IAAI;AAAA,MACR,iEAAiE,OAAO,SAAS,CAAC;AAAA,IACpF;AAAA,EACF;AAEA,QAAM,SAAS,KAAK,KAAK,YAAY,eAAe,IAAI;AAExD,QAAM,OAAO,CAAC,MAAuB,IAAI,YAAY,IAAI;AAEzD,QAAM,SAAyB,CAAC;AAChC,WAAS,IAAI,GAAG,KAAK,SAAS,iBAAiB,KAAK,GAAG;AACrD,WAAO,KAAK;AAAA,MACV,OAAO,CAAC,KAAK,SAAS,IAAI,IAAI,CAAC,GAAG,KAAK,IAAI,IAAI,CAAC,CAAC;AAAA,MACjD,MAAM,CAAC,KAAK,IAAI,CAAC,GAAG,KAAK,SAAS,IAAI,IAAI,CAAC,CAAC;AAAA,IAC9C,CAAC;AAAA,EACH;AAEA,SAAO,EAAE,iBAAiB,QAAQ,QAAQ,yBAAyB,KAAK;AAC1E;AAMO,SAAS,mBAAmB,WAAkC;AACnE,MAAI,CAAC,OAAO,UAAU,SAAS,KAAK,YAAY,EAAG,QAAO;AAC1D,QAAM,SAAS,KAAK,KAAK,YAAY,eAAe,IAAI;AACxD,MAAI,UAAU,wBAAyB,QAAO;AAC9C,SACE,mBAAmB,MAAM,WAAW,SAAS,eAAe,wBAC9C,uBAAuB,qLAEb,uBAAuB;AAEnD;AAIA,IAAM,iBAAiB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkEvB,SAAS,UAAU,MAAc,WAAsC;AAGrE,QAAM,OAAO,SAAS,IAAI,SAAY,UAAU,OAAO,CAAC;AACxD,MAAI,SAAS,QAAW;AACtB,WAAO;AAAA,EACT;AACA,SAAO,mCAAmC,IAAI,KAAK,IAAI;AACzD;AAEA,SAAS,WAAW,QAAmC,WAAsC;AAC3F,SAAO,4BAA4B,UAAU,OAAO,CAAC,GAAG,SAAS,CAAC,GAAG,UAAU,OAAO,CAAC,GAAG,SAAS,CAAC;AACtG;AAEA,SAAS,aAAa,YAA+B,SAAgC;AACnF,QAAM,aAAa,WAAW,OAAO;AACrC,QAAM,cAAc,UAChB,qCAAiC,gCAAW,OAAO,CAAC,YACpD;AACJ,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAUH,UAAU,SAAS,eAAe,IAAI,KAAK,GAAG,cAAc,WAAW,eAAe;AAAA,MACtF,WAAW;AAAA;AAEjB;AASO,SAAS,qBACd,WACA,MACQ;AACR,QAAM,QAAQ,MAAM,SAAS;AAC7B,QAAM,aAAa,MAAM,cAAc;AACvC,QAAM,aAAa,cAAc,KAAK,IAAI,GAAG,UAAU,MAAM,CAAC;AAC9D,QAAM,UAAU,mBAAmB,UAAU,MAAM;AAInD,QAAM,QAAQ,WAAW,OACtB,IAAI,CAAC,UAAU,WAAW,MAAM,OAAO,SAAS,IAAI,WAAW,MAAM,MAAM,SAAS,CAAC,EACrF,KAAK,IAAI;AAEZ,SAAO;AAAA;AAAA;AAAA;AAAA;AAAA,eAKE,gCAAW,KAAK,CAAC;AAAA;AAAA,EAE1B,cAAc;AAAA,EACd,UAAU;AAAA;AAAA;AAAA;AAAA,EAIV,aAAa,YAAY,OAAO,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA,EAKjC,KAAK;AAAA;AAAA;AAGP;AAGO,SAAS,aACd,WACA,MACc;AACd,QAAM,QAAQ,MAAM,SAAS;AAC7B,SAAO,gBAAgB,qBAAqB,WAAW,IAAI,GAAG,KAAK;AACrE;","names":["import_html_escape"]}
package/dist/booklet.js CHANGED
@@ -1,7 +1,5 @@
1
1
  // src/core.ts
2
- function escapeHtml(str) {
3
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
4
- }
2
+ import { escapeHtml } from "@ai-matrx/kit/html-escape";
5
3
  function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "width=920,height=720,scrollbars=yes") {
6
4
  const win = window.open("", "_blank", windowFeatures);
7
5
  if (!win) {
@@ -23,6 +21,7 @@ function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "wid
23
21
  }
24
22
 
25
23
  // src/booklet.ts
24
+ import { escapeHtml as escapeHtml2 } from "@ai-matrx/kit/html-escape";
26
25
  var PAGES_PER_SHEET = 4;
27
26
  var SADDLE_STITCH_MAX_PAGES = 48;
28
27
  function imposeBooklet(pageCount) {
@@ -125,7 +124,7 @@ function renderSide(spread, pagesHtml) {
125
124
  }
126
125
  function renderBanner(imposition, warning) {
127
126
  const sheetCount = imposition.sheets.length;
128
- const warningHtml = warning ? `<span class="bk-size-warning">${escapeHtml(warning)}</span>` : "";
127
+ const warningHtml = warning ? `<span class="bk-size-warning">${escapeHtml2(warning)}</span>` : "";
129
128
  return `<div class="bk-banner screen-only">
130
129
  <h2>Booklet print settings \u2014 set these exactly</h2>
131
130
  <ol>
@@ -151,7 +150,7 @@ function buildBookletDocument(pagesHtml, opts) {
151
150
  <head>
152
151
  <meta charset="UTF-8">
153
152
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
154
- <title>${escapeHtml(title)}</title>
153
+ <title>${escapeHtml2(title)}</title>
155
154
  <style>
156
155
  ${BOOKLET_STYLES}
157
156
  ${pageStyles}
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/core.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// ─── 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// ─── 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\nfunction escapeHtml(str: string): string {\n return str\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\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 */\nexport function openPrintWindow(\n htmlDocument: string,\n filename = \"print\",\n windowFeatures = \"width=920,height=720,scrollbars=yes\",\n): PrintOutcome {\n const win = window.open(\"\", \"_blank\", windowFeatures);\n if (!win) {\n // Popup blocked — fall back to file download\n const blob = new Blob([htmlDocument], { type: \"text/html;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `${filename.replace(/\\s+/g, \"-\").toLowerCase()}.html`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke on a macrotask so the browser has started the blob fetch —\n // a synchronous revoke races the download in some engines.\n setTimeout(() => URL.revokeObjectURL(url), 0);\n return \"downloaded\";\n }\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n return \"opened\";\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\nexport { escapeHtml };\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 { escapeHtml, openPrintWindow, type PrintOutcome } from \"./core\";\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":[]}
1
+ {"version":3,"sources":["../src/core.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 { escapeHtml } from \"@ai-matrx/kit/html-escape\";\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// ─── 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 */\nexport function openPrintWindow(\n htmlDocument: string,\n filename = \"print\",\n windowFeatures = \"width=920,height=720,scrollbars=yes\",\n): PrintOutcome {\n const win = window.open(\"\", \"_blank\", windowFeatures);\n if (!win) {\n // Popup blocked — fall back to file download\n const blob = new Blob([htmlDocument], { type: \"text/html;charset=utf-8\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = `${filename.replace(/\\s+/g, \"-\").toLowerCase()}.html`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n // Revoke on a macrotask so the browser has started the blob fetch —\n // a synchronous revoke races the download in some engines.\n setTimeout(() => URL.revokeObjectURL(url), 0);\n return \"downloaded\";\n }\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n return \"opened\";\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 * @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"]}
@@ -29,9 +29,7 @@ __export(certificate_exports, {
29
29
  module.exports = __toCommonJS(certificate_exports);
30
30
 
31
31
  // src/core.ts
32
- function escapeHtml(str) {
33
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
34
- }
32
+ var import_html_escape = require("@ai-matrx/kit/html-escape");
35
33
  function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "width=920,height=720,scrollbars=yes") {
36
34
  const win = window.open("", "_blank", windowFeatures);
37
35
  if (!win) {
@@ -52,7 +50,11 @@ function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "wid
52
50
  return "opened";
53
51
  }
54
52
 
53
+ // src/certificate.ts
54
+ var import_html_escape3 = require("@ai-matrx/kit/html-escape");
55
+
55
56
  // src/booklet.ts
57
+ var import_html_escape2 = require("@ai-matrx/kit/html-escape");
56
58
  var PAGES_PER_SHEET = 4;
57
59
  var SADDLE_STITCH_MAX_PAGES = 48;
58
60
  function imposeBooklet(pageCount) {
@@ -155,7 +157,7 @@ function renderSide(spread, pagesHtml) {
155
157
  }
156
158
  function renderBanner(imposition, warning) {
157
159
  const sheetCount = imposition.sheets.length;
158
- const warningHtml = warning ? `<span class="bk-size-warning">${escapeHtml(warning)}</span>` : "";
160
+ const warningHtml = warning ? `<span class="bk-size-warning">${(0, import_html_escape2.escapeHtml)(warning)}</span>` : "";
159
161
  return `<div class="bk-banner screen-only">
160
162
  <h2>Booklet print settings \u2014 set these exactly</h2>
161
163
  <ol>
@@ -181,7 +183,7 @@ function buildBookletDocument(pagesHtml, opts) {
181
183
  <head>
182
184
  <meta charset="UTF-8">
183
185
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
184
- <title>${escapeHtml(title)}</title>
186
+ <title>${(0, import_html_escape2.escapeHtml)(title)}</title>
185
187
  <style>
186
188
  ${BOOKLET_STYLES}
187
189
  ${pageStyles}
@@ -429,8 +431,8 @@ function renderSignatures(signatures) {
429
431
  const lines = signatures.map(
430
432
  (sig) => `<div class="cert-sig">
431
433
  <div class="cert-sig-rule"></div>
432
- ${sig.name ? `<div class="cert-sig-name">${escapeHtml(sig.name)}</div>` : ""}
433
- <div class="cert-sig-role">${escapeHtml(sig.role)}</div>
434
+ ${sig.name ? `<div class="cert-sig-name">${(0, import_html_escape3.escapeHtml)(sig.name)}</div>` : ""}
435
+ <div class="cert-sig-role">${(0, import_html_escape3.escapeHtml)(sig.role)}</div>
434
436
  </div>`
435
437
  ).join("");
436
438
  return `<div class="cert-signatures">${lines}</div>`;
@@ -444,12 +446,12 @@ function renderFormal(cert) {
444
446
  <span class="cert-corner cert-corner-br"></span>
445
447
  <div class="cert-frame-inner">
446
448
  <div class="cert-eyebrow">Certificate</div>
447
- <div class="cert-title">${escapeHtml(cert.title)}</div>
448
- ${cert.subtitle ? `<div class="cert-subtitle">${escapeHtml(cert.subtitle)}</div>` : ""}
449
+ <div class="cert-title">${(0, import_html_escape3.escapeHtml)(cert.title)}</div>
450
+ ${cert.subtitle ? `<div class="cert-subtitle">${(0, import_html_escape3.escapeHtml)(cert.subtitle)}</div>` : ""}
449
451
  <div class="cert-presented">This certifies that</div>
450
- <div class="cert-recipient">${escapeHtml(cert.recipientName)}</div>
451
- ${cert.body ? `<div class="cert-body">${escapeHtml(cert.body)}</div>` : ""}
452
- ${cert.date ? `<div class="cert-date">${escapeHtml(cert.date)}</div>` : ""}
452
+ <div class="cert-recipient">${(0, import_html_escape3.escapeHtml)(cert.recipientName)}</div>
453
+ ${cert.body ? `<div class="cert-body">${(0, import_html_escape3.escapeHtml)(cert.body)}</div>` : ""}
454
+ ${cert.date ? `<div class="cert-date">${(0, import_html_escape3.escapeHtml)(cert.date)}</div>` : ""}
453
455
  ${renderSignatures(cert.signatures ?? [])}
454
456
  </div>
455
457
  </div>
@@ -459,25 +461,25 @@ function renderModernMinimal(cert) {
459
461
  return `<div class="cert-page cert-modern">
460
462
  <div class="cert-rule-top"></div>
461
463
  <div class="cert-eyebrow">Certificate of completion</div>
462
- <div class="cert-title">${escapeHtml(cert.title)}</div>
463
- ${cert.subtitle ? `<div class="cert-subtitle">${escapeHtml(cert.subtitle)}</div>` : ""}
464
- <div class="cert-recipient">${escapeHtml(cert.recipientName)}</div>
465
- ${cert.body ? `<div class="cert-body">${escapeHtml(cert.body)}</div>` : ""}
466
- ${cert.date ? `<div class="cert-date">${escapeHtml(cert.date)}</div>` : ""}
464
+ <div class="cert-title">${(0, import_html_escape3.escapeHtml)(cert.title)}</div>
465
+ ${cert.subtitle ? `<div class="cert-subtitle">${(0, import_html_escape3.escapeHtml)(cert.subtitle)}</div>` : ""}
466
+ <div class="cert-recipient">${(0, import_html_escape3.escapeHtml)(cert.recipientName)}</div>
467
+ ${cert.body ? `<div class="cert-body">${(0, import_html_escape3.escapeHtml)(cert.body)}</div>` : ""}
468
+ ${cert.date ? `<div class="cert-date">${(0, import_html_escape3.escapeHtml)(cert.date)}</div>` : ""}
467
469
  ${renderSignatures(cert.signatures ?? [])}
468
470
  </div>`;
469
471
  }
470
472
  var ACK_STATEMENT = "I acknowledge that I have received, read and understand this document, and I agree to comply with its contents.";
471
473
  function renderAcknowledgment(cert) {
472
474
  return `<div class="cert-page cert-acknowledgment">
473
- <div class="cert-title">${escapeHtml(cert.title)}</div>
474
- ${cert.subtitle ? `<div class="cert-subtitle">${escapeHtml(cert.subtitle)}</div>` : ""}
475
- ${cert.body ? `<div class="cert-body">${escapeHtml(cert.body)}</div>` : ""}
476
- <div class="cert-ack-statement">${escapeHtml(ACK_STATEMENT)}</div>
475
+ <div class="cert-title">${(0, import_html_escape3.escapeHtml)(cert.title)}</div>
476
+ ${cert.subtitle ? `<div class="cert-subtitle">${(0, import_html_escape3.escapeHtml)(cert.subtitle)}</div>` : ""}
477
+ ${cert.body ? `<div class="cert-body">${(0, import_html_escape3.escapeHtml)(cert.body)}</div>` : ""}
478
+ <div class="cert-ack-statement">${(0, import_html_escape3.escapeHtml)(ACK_STATEMENT)}</div>
477
479
  <div class="cert-ack-fields">
478
480
  <div class="cert-ack-field">
479
481
  <div class="cert-ack-rule"></div>
480
- <div class="cert-ack-label">Printed name${cert.recipientName ? ` \u2014 ${escapeHtml(cert.recipientName)}` : ""}</div>
482
+ <div class="cert-ack-label">Printed name${cert.recipientName ? ` \u2014 ${(0, import_html_escape3.escapeHtml)(cert.recipientName)}` : ""}</div>
481
483
  </div>
482
484
  <div class="cert-ack-field">
483
485
  <div class="cert-ack-rule"></div>
@@ -485,7 +487,7 @@ function renderAcknowledgment(cert) {
485
487
  </div>
486
488
  <div class="cert-ack-field cert-ack-field-date">
487
489
  <div class="cert-ack-rule"></div>
488
- <div class="cert-ack-label">Date${cert.date ? ` \u2014 ${escapeHtml(cert.date)}` : ""}</div>
490
+ <div class="cert-ack-label">Date${cert.date ? ` \u2014 ${(0, import_html_escape3.escapeHtml)(cert.date)}` : ""}</div>
489
491
  </div>
490
492
  </div>
491
493
  ${renderSignatures(cert.signatures ?? [])}
@@ -518,7 +520,7 @@ function buildCertificateDocument(cert, variant) {
518
520
  <html lang="en">
519
521
  <head>
520
522
  <meta charset="UTF-8">
521
- <title>${escapeHtml(cert.title)}</title>
523
+ <title>${(0, import_html_escape3.escapeHtml)(cert.title)}</title>
522
524
  <style>
523
525
  ${stylesFor(variant)}
524
526
  </style>
@@ -663,27 +665,27 @@ function CERT_RESET_FOR_WORKBOOK() {
663
665
  }
664
666
  function renderCoverPage(spec) {
665
667
  return `<div class="wb-cover">
666
- <div class="wb-cover-title">${escapeHtml(spec.title)}</div>
668
+ <div class="wb-cover-title">${(0, import_html_escape3.escapeHtml)(spec.title)}</div>
667
669
  <div class="wb-cover-rule"></div>
668
- ${spec.subtitle ? `<div class="wb-cover-subtitle">${escapeHtml(spec.subtitle)}</div>` : ""}
670
+ ${spec.subtitle ? `<div class="wb-cover-subtitle">${(0, import_html_escape3.escapeHtml)(spec.subtitle)}</div>` : ""}
669
671
  </div>`;
670
672
  }
671
673
  function renderSectionTitlePage(section, index) {
672
674
  return `<div class="wb-section-title-page" data-section="${index + 1}">
673
675
  <div class="wb-section-eyebrow">Section ${index + 1}</div>
674
- <div class="wb-section-name">${escapeHtml(section.title)}</div>
676
+ <div class="wb-section-name">${(0, import_html_escape3.escapeHtml)(section.title)}</div>
675
677
  </div>`;
676
678
  }
677
679
  function renderTocPage(spec, entries) {
678
680
  const rows = entries.map(
679
681
  (entry) => `<div class="wb-toc-row" data-toc-page="${entry.page}">
680
- <span class="wb-toc-label">${escapeHtml(entry.title)}</span>
682
+ <span class="wb-toc-label">${(0, import_html_escape3.escapeHtml)(entry.title)}</span>
681
683
  <span class="wb-toc-dots"></span>
682
684
  <span class="wb-toc-page">${entry.page}</span>
683
685
  </div>`
684
686
  ).join("");
685
687
  return `<div class="wb-toc">
686
- <h2>${escapeHtml(spec.title)} \u2014 Contents</h2>
688
+ <h2>${(0, import_html_escape3.escapeHtml)(spec.title)} \u2014 Contents</h2>
687
689
  ${rows}
688
690
  </div>`;
689
691
  }
@@ -727,7 +729,7 @@ ${extra}`
727
729
  <html lang="en">
728
730
  <head>
729
731
  <meta charset="UTF-8">
730
- <title>${escapeHtml(spec.title)}</title>
732
+ <title>${(0, import_html_escape3.escapeHtml)(spec.title)}</title>
731
733
  <style>
732
734
  ${WORKBOOK_SEQUENTIAL_STYLES}
733
735
  ${extra}