@ai-matrx/print 0.1.0 → 0.2.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 +56 -0
- package/dist/barcode.cjs +182 -0
- package/dist/barcode.cjs.map +1 -0
- package/dist/barcode.d.cts +94 -0
- package/dist/barcode.d.ts +94 -0
- package/dist/barcode.js +151 -0
- package/dist/barcode.js.map +1 -0
- package/dist/booklet.cjs +203 -0
- package/dist/booklet.cjs.map +1 -0
- package/dist/booklet.d.cts +93 -0
- package/dist/booklet.d.ts +93 -0
- package/dist/booklet.js +180 -0
- package/dist/booklet.js.map +1 -0
- package/dist/core.cjs +6 -5
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +25 -4
- package/dist/core.d.ts +25 -4
- package/dist/core.js +6 -5
- package/dist/core.js.map +1 -1
- package/dist/flashcards.cjs +18 -65
- package/dist/flashcards.cjs.map +1 -1
- package/dist/flashcards.js +18 -65
- package/dist/flashcards.js.map +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -1
- package/dist/index.d.ts +4 -1
- package/dist/labels.cjs +10 -11
- package/dist/labels.cjs.map +1 -1
- package/dist/labels.d.cts +3 -3
- package/dist/labels.d.ts +3 -3
- package/dist/labels.js +10 -11
- package/dist/labels.js.map +1 -1
- package/dist/qr-styled.cjs +219 -0
- package/dist/qr-styled.cjs.map +1 -0
- package/dist/qr-styled.d.cts +150 -0
- package/dist/qr-styled.d.ts +150 -0
- package/dist/qr-styled.js +188 -0
- package/dist/qr-styled.js.map +1 -0
- package/dist/react.cjs +23 -18
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +26 -3
- package/dist/react.d.ts +26 -3
- package/dist/react.js +23 -18
- package/dist/react.js.map +1 -1
- package/package.json +34 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/barcode.ts"],"sourcesContent":["/**\n * @ai-matrx/print/barcode — 1-D BARCODE GENERATION for retail and warehouse labels.\n *\n * New code (no port source). Scanning/decoding is NOT here. This module only\n * ever turns a value into a linear symbol, in the three symbologies that\n * actually show up on the commerce lane's labels:\n *\n * - `code128` — the general-purpose workhorse. The `manufacturer_serial`\n * identifier kind is usually printed as Code 128: arbitrary printable ASCII,\n * dense, and every warehouse scanner reads it.\n * - `ean13` / `upca` — retail GTINs. These are NOT free-form strings: they\n * carry a GS1 mod-10 check digit, and a label printed with the wrong one is\n * a label that gets rejected at the point of sale.\n *\n * Two rules that are NOT style choices:\n * - EVERY generate call routes through `normalizeBarcodeValue` first. A caller\n * that hands us 12 digits gets the check digit APPENDED; a caller that hands\n * us 13 gets it VERIFIED and throws on a mismatch. There is no path that\n * prints an unvalidated retail symbol.\n * - The human-readable line defaults ON (`includeText`). On a retail or\n * warehouse label the printed digits are the fallback when the scanner\n * fails, and a keyer at a register cannot read bars.\n *\n * The module is import-inert: `bwip-js` is only ever pulled in via\n * `await import(\"bwip-js\")` inside a function body, so importing this file from\n * a React Server Component (or any bundle that never draws a barcode) costs\n * nothing. No module-level mutable state, no React, no DOM at import time.\n *\n * SVG works EVERYWHERE — bwip-js `toSVG` is pure JS with no canvas dependency,\n * so it is the lane for Node (server-side PDF/label composition) as much as for\n * the browser. The PNG data-URL lane is browser-only by design: it uses\n * bwip-js's canvas rendering rather than dragging `node-canvas` into a package\n * that mostly runs in a print window. In Node it throws and names the fix.\n */\n\n/**\n * We import the \"bwip-js/browser\" SUBPATH rather than the bare \"bwip-js\"\n * specifier, deliberately and for two reasons:\n * 1. bwip-js's export map has no `default` condition — only `browser`, `node`,\n * `electron` and `react-native` — so a bundler-style TypeScript resolver\n * cannot resolve the bare specifier at all. The subpath resolves cleanly.\n * 2. The browser build is the SUPERSET for our purposes: its `toSVG` is pure\n * JS and imports/runs fine under Node (verified — nothing touches `document`\n * at module scope), and it is the only build that carries `toCanvas`, which\n * the PNG lane needs. The node build would give us `toBuffer` instead and\n * no canvas lane at all.\n */\nimport type { RenderOptions } from \"bwip-js/browser\";\n\n// ─── Contract ────────────────────────────────────────────────────────────────\n\n/** The three 1-D symbologies the platform prints. Values are bwip-js `bcid`s verbatim. */\nexport type BarcodeSymbology = \"code128\" | \"ean13\" | \"upca\";\n\nexport interface BarcodeOptions {\n /**\n * Bar height. This is bwip-js's `height`, whose unit is MILLIMETRES (it is\n * not a module count — the name follows the package's option contract).\n * Defaults per symbology: 12 for Code 128, 22.85 for EAN-13/UPC-A, the GS1\n * nominal symbol height at 100% magnification.\n */\n heightModules?: number;\n /** Human-readable line under the bars. Default TRUE — it is the fallback when a scan fails. */\n includeText?: boolean;\n /** Module width multiplier (bwip-js `scale`). Default 2. */\n scale?: number;\n}\n\n/** Default module-width multiplier for every Matrx-printed barcode. */\nexport const DEFAULT_BARCODE_SCALE = 2;\n\n/**\n * Default bar height in millimetres, per symbology.\n * 22.85mm is the GS1 nominal EAN-13/UPC-A symbol height at 100% magnification;\n * Code 128 has no spec height, and 12mm is a comfortable warehouse-label bar.\n */\nexport const DEFAULT_BARCODE_HEIGHT: Readonly<Record<BarcodeSymbology, number>> = {\n code128: 12,\n ean13: 22.85,\n upca: 22.85,\n};\n\n/** Digit counts each GS1 symbology accepts: [without check digit, with check digit]. */\nconst GS1_DIGIT_LENGTHS: Readonly<Record<\"ean13\" | \"upca\", readonly [number, number]>> = {\n ean13: [12, 13],\n upca: [11, 12],\n};\n\nconst SYMBOLOGIES: readonly BarcodeSymbology[] = [\"code128\", \"ean13\", \"upca\"];\n\nfunction assertSymbology(symbology: BarcodeSymbology): BarcodeSymbology {\n if (!SYMBOLOGIES.includes(symbology)) {\n throw new Error(\n `Unknown barcode symbology \"${String(symbology)}\". Expected one of ${SYMBOLOGIES.map((s) => `\"${s}\"`).join(\", \")}.`,\n );\n }\n return symbology;\n}\n\n// ─── Validation ──────────────────────────────────────────────────────────────\n\n/**\n * GS1 mod-10 check digit over the payload digits (i.e. WITHOUT the check\n * digit): weights 3 and 1 alternating from the right. Length-independent, so\n * UPC-A's 11-digit payload and EAN-13's 12-digit payload use the same routine.\n */\nfunction gs1CheckDigit(payloadDigits: string): number {\n let sum = 0;\n let weight = 3;\n for (let i = payloadDigits.length - 1; i >= 0; i -= 1) {\n sum += (payloadDigits.charCodeAt(i) - 48) * weight;\n weight = weight === 3 ? 1 : 3;\n }\n return (10 - (sum % 10)) % 10;\n}\n\n/**\n * Code 128 encodes ASCII 0–127, but a label only prints what a human can read\n * back, so we accept the printable range 0x20 (space) through 0x7E (tilde).\n * Control characters and anything non-ASCII (smart quotes pasted from a\n * spreadsheet are the usual culprit) are rejected loudly rather than silently\n * mangled by the encoder.\n */\nfunction normalizeCode128(value: string): string {\n if (value.length === 0) {\n throw new Error(\"Code 128 barcode value must be a non-empty string.\");\n }\n for (let i = 0; i < value.length; i += 1) {\n const code = value.charCodeAt(i);\n if (code < 0x20 || code > 0x7e) {\n throw new Error(\n `Code 128 barcode value contains a non-printable-ASCII character ${JSON.stringify(value.charAt(i))} ` +\n `at index ${i} (char code ${code}). Only ASCII 0x20-0x7E can be printed and read back by a human.`,\n );\n }\n }\n return value;\n}\n\nfunction normalizeGs1(value: string, symbology: \"ean13\" | \"upca\"): string {\n const raw = value.trim();\n if (raw.length === 0) {\n throw new Error(`${symbology === \"ean13\" ? \"EAN-13\" : \"UPC-A\"} barcode value must be a non-empty string.`);\n }\n if (!/^[0-9]+$/.test(raw)) {\n throw new Error(\n `${symbology === \"ean13\" ? \"EAN-13\" : \"UPC-A\"} barcode value \"${raw}\" must contain digits only ` +\n `(no spaces, dashes, or letters).`,\n );\n }\n\n const lengths = GS1_DIGIT_LENGTHS[symbology];\n const [withoutCheck, withCheck] = lengths;\n const label = symbology === \"ean13\" ? \"EAN-13\" : \"UPC-A\";\n\n if (raw.length === withoutCheck) {\n // Caller gave us the payload — APPEND the check digit we compute.\n return `${raw}${gs1CheckDigit(raw)}`;\n }\n if (raw.length === withCheck) {\n // Caller gave us a full code — VERIFY the check digit they already have.\n const expected = gs1CheckDigit(raw.slice(0, -1));\n const actual = raw.charCodeAt(raw.length - 1) - 48;\n if (expected !== actual) {\n throw new Error(\n `${label} barcode value \"${raw}\" has an invalid GS1 check digit: expected ${expected}, got ${actual}. ` +\n `Pass the first ${withoutCheck} digits instead and the check digit will be computed for you.`,\n );\n }\n return raw;\n }\n throw new Error(\n `${label} barcode value \"${raw}\" must be ${withoutCheck} digits (check digit appended) or ` +\n `${withCheck} digits (check digit verified), got ${raw.length}.`,\n );\n}\n\n/**\n * Validate and normalize a value for a symbology, returning the FULL value that\n * will actually be encoded.\n *\n * - `ean13` / `upca`: digits only. 12 (EAN-13) or 11 (UPC-A) digits get the\n * GS1 mod-10 check digit APPENDED; 13 or 12 digits get theirs VERIFIED and\n * throw on a mismatch. Either way the return value is the complete code.\n * - `code128`: printable ASCII (0x20-0x7E) passes through untouched —\n * including leading/trailing spaces, which are legal Code 128 data.\n *\n * Throws an Error with a message naming the exact problem. Every generator in\n * this module routes through here first; call it directly when you need to show\n * the normalized value (e.g. the digits under the bars) before printing.\n */\nexport function normalizeBarcodeValue(value: string, symbology: BarcodeSymbology): string {\n assertSymbology(symbology);\n if (typeof value !== \"string\") {\n throw new Error(`Barcode value must be a string, got ${String(value)}.`);\n }\n if (symbology === \"code128\") {\n return normalizeCode128(value);\n }\n return normalizeGs1(value, symbology);\n}\n\n// ─── Option resolution ───────────────────────────────────────────────────────\n\nfunction resolveScale(scale: number | undefined): number {\n if (scale === undefined) return DEFAULT_BARCODE_SCALE;\n if (typeof scale !== \"number\" || !Number.isFinite(scale) || scale <= 0) {\n throw new Error(`Barcode scale must be a positive number, got ${String(scale)}.`);\n }\n return scale;\n}\n\nfunction resolveHeight(heightModules: number | undefined, symbology: BarcodeSymbology): number {\n if (heightModules === undefined) return DEFAULT_BARCODE_HEIGHT[symbology];\n if (typeof heightModules !== \"number\" || !Number.isFinite(heightModules) || heightModules <= 0) {\n throw new Error(`Barcode heightModules must be a positive number, got ${String(heightModules)}.`);\n }\n return heightModules;\n}\n\n/**\n * Build the bwip-js render options. Note the object is fully populated here —\n * under `exactOptionalPropertyTypes` we never assign `undefined` to an optional\n * key, so every field is either a real value or absent.\n */\nfunction buildRenderOptions(value: string, symbology: BarcodeSymbology, opts: BarcodeOptions | undefined): RenderOptions {\n const normalized = normalizeBarcodeValue(value, symbology);\n return {\n bcid: symbology,\n text: normalized,\n scale: resolveScale(opts?.scale),\n height: resolveHeight(opts?.heightModules, symbology),\n includetext: opts?.includeText === undefined ? true : opts.includeText === true,\n textxalign: \"center\",\n };\n}\n\n// ─── Rendering ───────────────────────────────────────────────────────────────\n\n/**\n * SVG markup string — the lane that works EVERYWHERE (Node and browser), since\n * bwip-js `toSVG` is pure JS with no canvas dependency. Lazy-imports `bwip-js`\n * at call time, so this module stays import-inert.\n *\n * The value is validated and normalized first; a bad GS1 check digit throws\n * here rather than printing a label nobody can sell.\n */\nexport async function generateBarcodeSvg(\n value: string,\n symbology: BarcodeSymbology,\n opts?: BarcodeOptions,\n): Promise<string> {\n const options = buildRenderOptions(value, symbology, opts);\n const bwipjs = await import(\"bwip-js/browser\");\n return bwipjs.toSVG(options);\n}\n\n/**\n * `toCanvas` only exists in bwip-js's browser build (the node build ships\n * `toBuffer` instead), so even on the browser subpath we PROBE for it rather\n * than assume which condition a host bundler ultimately resolved.\n */\ninterface BwipCanvasLane {\n toCanvas?: (canvas: HTMLCanvasElement, opts: RenderOptions) => HTMLCanvasElement;\n}\n\nconst USE_SVG_INSTEAD =\n \"Use generateBarcodeSvg() instead — SVG renders identically, works in Node and the browser, \" +\n \"and prints at the printer's resolution rather than the canvas's.\";\n\n/**\n * PNG data URL, for embedding straight into a print window's `<img src>`.\n *\n * BROWSER ONLY. In Node this throws immediately and names the fix rather than\n * dragging `node-canvas` (a native build) into a package that mostly runs in a\n * print window. The same clear error is thrown in a DOM environment whose\n * canvas is a stub (jsdom without the optional `canvas` package returns `null`\n * from `toDataURL`) — we would rather fail loudly than hand back a broken\n * `src` that silently renders as a missing image.\n */\nexport async function generateBarcodeDataUrl(\n value: string,\n symbology: BarcodeSymbology,\n opts?: BarcodeOptions,\n): Promise<string> {\n const options = buildRenderOptions(value, symbology, opts);\n\n if (typeof document === \"undefined\") {\n throw new Error(`generateBarcodeDataUrl() needs a DOM canvas and cannot run in Node. ${USE_SVG_INSTEAD}`);\n }\n\n const bwipjs = (await import(\"bwip-js/browser\")) as unknown as BwipCanvasLane;\n const toCanvas = bwipjs.toCanvas;\n if (typeof toCanvas !== \"function\") {\n throw new Error(\n `bwip-js resolved to its non-browser build, which has no toCanvas() — barcode PNG rendering is ` +\n `unavailable in this environment. ${USE_SVG_INSTEAD}`,\n );\n }\n\n let canvas: HTMLCanvasElement;\n try {\n canvas = toCanvas(document.createElement(\"canvas\"), options);\n } catch (cause) {\n // A DOM without a real 2-D context (jsdom without the optional `canvas`\n // package is the common one) fails somewhere inside bwip-js's drawing\n // code. Re-throw with the fix instead of a stray TypeError.\n throw new Error(\n `This environment's canvas could not render the barcode (${cause instanceof Error ? cause.message : String(cause)}). ` +\n `${USE_SVG_INSTEAD}`,\n { cause },\n );\n }\n const dataUrl: unknown = canvas.toDataURL(\"image/png\");\n if (typeof dataUrl !== \"string\" || !dataUrl.startsWith(\"data:image/png\")) {\n throw new Error(\n `This environment's canvas produced no PNG data URL (got ${String(dataUrl)}) — barcode PNG rendering ` +\n `is unavailable here. ${USE_SVG_INSTEAD}`,\n );\n }\n return dataUrl;\n}\n"],"mappings":"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","names":[]}
|
package/dist/booklet.cjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/booklet.ts
|
|
21
|
+
var booklet_exports = {};
|
|
22
|
+
__export(booklet_exports, {
|
|
23
|
+
bookletSizeWarning: () => bookletSizeWarning,
|
|
24
|
+
buildBookletDocument: () => buildBookletDocument,
|
|
25
|
+
imposeBooklet: () => imposeBooklet,
|
|
26
|
+
printBooklet: () => printBooklet
|
|
27
|
+
});
|
|
28
|
+
module.exports = __toCommonJS(booklet_exports);
|
|
29
|
+
|
|
30
|
+
// src/core.ts
|
|
31
|
+
function escapeHtml(str) {
|
|
32
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
33
|
+
}
|
|
34
|
+
function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "width=920,height=720,scrollbars=yes") {
|
|
35
|
+
const win = window.open("", "_blank", windowFeatures);
|
|
36
|
+
if (!win) {
|
|
37
|
+
const blob = new Blob([htmlDocument], { type: "text/html;charset=utf-8" });
|
|
38
|
+
const url = URL.createObjectURL(blob);
|
|
39
|
+
const a = document.createElement("a");
|
|
40
|
+
a.href = url;
|
|
41
|
+
a.download = `${filename.replace(/\s+/g, "-").toLowerCase()}.html`;
|
|
42
|
+
document.body.appendChild(a);
|
|
43
|
+
a.click();
|
|
44
|
+
document.body.removeChild(a);
|
|
45
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
46
|
+
return "downloaded";
|
|
47
|
+
}
|
|
48
|
+
win.document.open();
|
|
49
|
+
win.document.write(htmlDocument);
|
|
50
|
+
win.document.close();
|
|
51
|
+
return "opened";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/booklet.ts
|
|
55
|
+
var PAGES_PER_SHEET = 4;
|
|
56
|
+
var SADDLE_STITCH_MAX_PAGES = 48;
|
|
57
|
+
function imposeBooklet(pageCount) {
|
|
58
|
+
if (!Number.isInteger(pageCount) || pageCount < 1) {
|
|
59
|
+
throw new RangeError(
|
|
60
|
+
`imposeBooklet: pageCount must be a positive integer, received ${String(pageCount)}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const padded = Math.ceil(pageCount / PAGES_PER_SHEET) * PAGES_PER_SHEET;
|
|
64
|
+
const slot = (n) => n > pageCount ? 0 : n;
|
|
65
|
+
const sheets = [];
|
|
66
|
+
for (let i = 1; i <= padded / PAGES_PER_SHEET; i += 1) {
|
|
67
|
+
sheets.push({
|
|
68
|
+
front: [slot(padded - 2 * i + 2), slot(2 * i - 1)],
|
|
69
|
+
back: [slot(2 * i), slot(padded - 2 * i + 1)]
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return { paddedPageCount: padded, sheets, requiresShortEdgeDuplex: true };
|
|
73
|
+
}
|
|
74
|
+
function bookletSizeWarning(pageCount) {
|
|
75
|
+
if (!Number.isInteger(pageCount) || pageCount < 1) return null;
|
|
76
|
+
const padded = Math.ceil(pageCount / PAGES_PER_SHEET) * PAGES_PER_SHEET;
|
|
77
|
+
if (padded <= SADDLE_STITCH_MAX_PAGES) return null;
|
|
78
|
+
return `This booklet is ${padded} pages (${padded / PAGES_PER_SHEET} sheets). Past about ${SADDLE_STITCH_MAX_PAGES} pages a saddle-stitch booklet gets too thick to fold flat and the inner pages creep out at the fore-edge. Consider printing it as a plain workbook instead, or splitting it into ${SADDLE_STITCH_MAX_PAGES}-page signatures.`;
|
|
79
|
+
}
|
|
80
|
+
var BOOKLET_STYLES = `
|
|
81
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
82
|
+
|
|
83
|
+
@page { size: 11in 8.5in; margin: 0; }
|
|
84
|
+
|
|
85
|
+
html, body {
|
|
86
|
+
background: #fff;
|
|
87
|
+
color: #1a1a1a;
|
|
88
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.bk-side {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: row;
|
|
94
|
+
width: 11in;
|
|
95
|
+
height: 8.5in;
|
|
96
|
+
page-break-after: always;
|
|
97
|
+
break-after: page;
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
}
|
|
100
|
+
.bk-side:last-of-type { page-break-after: auto; break-after: auto; }
|
|
101
|
+
|
|
102
|
+
.bk-page {
|
|
103
|
+
width: 5.5in;
|
|
104
|
+
height: 8.5in;
|
|
105
|
+
overflow: hidden;
|
|
106
|
+
position: relative;
|
|
107
|
+
}
|
|
108
|
+
.bk-blank { /* intentionally empty \u2014 a padded slot */ }
|
|
109
|
+
|
|
110
|
+
.bk-banner {
|
|
111
|
+
background: #fef3c7;
|
|
112
|
+
border: 2px solid #f59e0b;
|
|
113
|
+
border-radius: 8px;
|
|
114
|
+
padding: 14px 18px;
|
|
115
|
+
margin: 18px;
|
|
116
|
+
font-size: 13px;
|
|
117
|
+
line-height: 1.55;
|
|
118
|
+
color: #78350f;
|
|
119
|
+
max-width: 11in;
|
|
120
|
+
}
|
|
121
|
+
.bk-banner h2 { font-size: 15px; margin-bottom: 6px; }
|
|
122
|
+
.bk-banner ol { margin: 6px 0 6px 20px; }
|
|
123
|
+
.bk-banner strong { font-weight: 700; }
|
|
124
|
+
.bk-banner .bk-size-warning {
|
|
125
|
+
display: block;
|
|
126
|
+
margin-top: 10px;
|
|
127
|
+
padding-top: 10px;
|
|
128
|
+
border-top: 1px solid #f59e0b;
|
|
129
|
+
color: #7f1d1d;
|
|
130
|
+
font-weight: 600;
|
|
131
|
+
}
|
|
132
|
+
.bk-actions { display: flex; gap: 10px; margin: 0 18px 18px; }
|
|
133
|
+
.bk-btn {
|
|
134
|
+
padding: 8px 20px; border-radius: 8px; font-size: 13px; font-family: inherit;
|
|
135
|
+
font-weight: 600; cursor: pointer; border: none;
|
|
136
|
+
}
|
|
137
|
+
.bk-btn-primary { background: #2563eb; color: #fff; }
|
|
138
|
+
.bk-btn-secondary { background: #f3f4f6; color: #374151; border: 1px solid #d1d5db; }
|
|
139
|
+
|
|
140
|
+
@media print {
|
|
141
|
+
.screen-only { display: none !important; }
|
|
142
|
+
.bk-side { page-break-after: always; break-after: page; }
|
|
143
|
+
}
|
|
144
|
+
`;
|
|
145
|
+
function renderBox(page, pagesHtml) {
|
|
146
|
+
const html = page === 0 ? void 0 : pagesHtml[page - 1];
|
|
147
|
+
if (html === void 0) {
|
|
148
|
+
return `<div class="bk-page bk-blank" data-page="0"></div>`;
|
|
149
|
+
}
|
|
150
|
+
return `<div class="bk-page" data-page="${page}">${html}</div>`;
|
|
151
|
+
}
|
|
152
|
+
function renderSide(spread, pagesHtml) {
|
|
153
|
+
return `<section class="bk-side">${renderBox(spread[0], pagesHtml)}${renderBox(spread[1], pagesHtml)}</section>`;
|
|
154
|
+
}
|
|
155
|
+
function renderBanner(imposition, warning) {
|
|
156
|
+
const sheetCount = imposition.sheets.length;
|
|
157
|
+
const warningHtml = warning ? `<span class="bk-size-warning">${escapeHtml(warning)}</span>` : "";
|
|
158
|
+
return `<div class="bk-banner screen-only">
|
|
159
|
+
<h2>Booklet print settings \u2014 set these exactly</h2>
|
|
160
|
+
<ol>
|
|
161
|
+
<li>Orientation: <strong>Landscape</strong></li>
|
|
162
|
+
<li>Two-sided printing: <strong>ON</strong></li>
|
|
163
|
+
<li>Flip on <strong>SHORT edge</strong> (also called "tumble" or "short-edge binding") \u2014 long-edge flip prints every back side upside-down</li>
|
|
164
|
+
<li>Scale: <strong>100%</strong> (never "fit to page")</li>
|
|
165
|
+
<li>Margins: <strong>None</strong></li>
|
|
166
|
+
</ol>
|
|
167
|
+
<p><strong>Print all pages, fold the whole stack in half, staple the spine.</strong>
|
|
168
|
+
${sheetCount} sheet${sheetCount === 1 ? "" : "s"} of paper, ${imposition.paddedPageCount} booklet pages.</p>
|
|
169
|
+
${warningHtml}
|
|
170
|
+
</div>`;
|
|
171
|
+
}
|
|
172
|
+
function buildBookletDocument(pagesHtml, opts) {
|
|
173
|
+
const title = opts?.title ?? "Booklet";
|
|
174
|
+
const pageStyles = opts?.pageStyles ?? "";
|
|
175
|
+
const imposition = imposeBooklet(Math.max(1, pagesHtml.length));
|
|
176
|
+
const warning = bookletSizeWarning(pagesHtml.length);
|
|
177
|
+
const sides = imposition.sheets.map((sheet) => renderSide(sheet.front, pagesHtml) + renderSide(sheet.back, pagesHtml)).join("\n");
|
|
178
|
+
return `<!DOCTYPE html>
|
|
179
|
+
<html lang="en">
|
|
180
|
+
<head>
|
|
181
|
+
<meta charset="UTF-8">
|
|
182
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
183
|
+
<title>${escapeHtml(title)}</title>
|
|
184
|
+
<style>
|
|
185
|
+
${BOOKLET_STYLES}
|
|
186
|
+
${pageStyles}
|
|
187
|
+
</style>
|
|
188
|
+
</head>
|
|
189
|
+
<body>
|
|
190
|
+
${renderBanner(imposition, warning)}
|
|
191
|
+
<div class="bk-actions screen-only">
|
|
192
|
+
<button class="bk-btn bk-btn-primary" onclick="window.print()">Print / Save as PDF</button>
|
|
193
|
+
<button class="bk-btn bk-btn-secondary" onclick="window.close()">Close</button>
|
|
194
|
+
</div>
|
|
195
|
+
${sides}
|
|
196
|
+
</body>
|
|
197
|
+
</html>`;
|
|
198
|
+
}
|
|
199
|
+
function printBooklet(pagesHtml, opts) {
|
|
200
|
+
const title = opts?.title ?? "Booklet";
|
|
201
|
+
return openPrintWindow(buildBookletDocument(pagesHtml, opts), title);
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=booklet.cjs.map
|
|
@@ -0,0 +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, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\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":[]}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { PrintOutcome } from './core.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @ai-matrx/print/booklet — saddle-stitch booklet imposition.
|
|
5
|
+
*
|
|
6
|
+
* The education white space: no test-prep / worksheet tool offers "turn this
|
|
7
|
+
* into a fold-and-staple booklet". This module is the whole capability —
|
|
8
|
+
* the imposition math, the size guidance, and a 2-up landscape print lane
|
|
9
|
+
* composed over ./core.
|
|
10
|
+
*
|
|
11
|
+
* Zero dependencies: pure arithmetic plus string composition. Import-inert —
|
|
12
|
+
* `window` is touched only inside printBooklet, at call time.
|
|
13
|
+
*
|
|
14
|
+
* ── THE MATH ────────────────────────────────────────────────────────────────
|
|
15
|
+
* A saddle-stitch booklet is a stack of sheets folded in half together and
|
|
16
|
+
* stapled through the spine. Every sheet carries FOUR logical pages (two per
|
|
17
|
+
* side), so the page count is padded up to a multiple of 4 with blanks.
|
|
18
|
+
*
|
|
19
|
+
* For sheet i (1-indexed, i = 1..N/4) of an N-page (padded) booklet:
|
|
20
|
+
* front spread = [N − 2i + 2, 2i − 1] (left, right)
|
|
21
|
+
* back spread = [2i, N − 2i + 1] (left, right)
|
|
22
|
+
*
|
|
23
|
+
* Worked anchor, N = 8:
|
|
24
|
+
* sheet 1 → front [8, 1] back [2, 7]
|
|
25
|
+
* sheet 2 → front [6, 3] back [4, 5]
|
|
26
|
+
* Every spread's two page numbers sum to N + 1 — the invariant that makes the
|
|
27
|
+
* fold work, and the cheapest way to check an imposition is right.
|
|
28
|
+
*
|
|
29
|
+
* ── THE DUPLEX TRAP ─────────────────────────────────────────────────────────
|
|
30
|
+
* These spreads are 2-up LANDSCAPE. Ordinary duplex printing flips on the LONG
|
|
31
|
+
* edge, which for a landscape sheet means the back side lands upside-down
|
|
32
|
+
* relative to the front — the booklet folds into gibberish. There are exactly
|
|
33
|
+
* two fixes: instruct the human to select short-edge (tumble) duplex, or
|
|
34
|
+
* pre-rotate every back side 180°. A print-window lane cannot pre-rotate
|
|
35
|
+
* reliably (browsers differ on how `@page`/transform interact with duplex), so
|
|
36
|
+
* THIS lane instructs. `BookletImposition.requiresShortEdgeDuplex` exposes the
|
|
37
|
+
* trap in the type system so a future PDF lane — which owns its own page
|
|
38
|
+
* transforms — can take the pre-rotate branch instead of the instruction.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One physical sheet of paper. Each tuple is [left, right] as the reader sees
|
|
43
|
+
* that side laid flat, in 1-based logical page numbers.
|
|
44
|
+
* `0` means a blank pad page (a slot past the end of the supplied content).
|
|
45
|
+
*/
|
|
46
|
+
interface BookletSheet {
|
|
47
|
+
front: [number, number];
|
|
48
|
+
back: [number, number];
|
|
49
|
+
}
|
|
50
|
+
interface BookletImposition {
|
|
51
|
+
/** Supplied page count rounded UP to the next multiple of 4. */
|
|
52
|
+
paddedPageCount: number;
|
|
53
|
+
/** Sheets in print order: sheet 1 outermost, last sheet at the fold. */
|
|
54
|
+
sheets: BookletSheet[];
|
|
55
|
+
/**
|
|
56
|
+
* true when ordinary long-edge duplex would flip the backs — consumer must
|
|
57
|
+
* either instruct short-edge/tumble duplex or pre-rotate backs 180° (PDF
|
|
58
|
+
* lanes). Always true for this 2-up landscape imposition.
|
|
59
|
+
*/
|
|
60
|
+
requiresShortEdgeDuplex: true;
|
|
61
|
+
}
|
|
62
|
+
interface BookletPrintOptions {
|
|
63
|
+
title?: string;
|
|
64
|
+
/** CSS injected after the layout styles — style the caller's page content. */
|
|
65
|
+
pageStyles?: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Impose `pageCount` logical pages onto saddle-stitch sheets.
|
|
69
|
+
*
|
|
70
|
+
* Padding is appended at the END of the logical page run, so pad slots always
|
|
71
|
+
* carry the HIGHEST page numbers — which is why they surface as `0` in the
|
|
72
|
+
* spreads nearest the front cover (N=1 → sheet 1 front [0, 1], back [0, 0]).
|
|
73
|
+
*
|
|
74
|
+
* @throws RangeError when pageCount is not a positive integer.
|
|
75
|
+
*/
|
|
76
|
+
declare function imposeBooklet(pageCount: number): BookletImposition;
|
|
77
|
+
/**
|
|
78
|
+
* Human warning when a booklet is too fat to saddle-stitch well, else null.
|
|
79
|
+
* Measured on the PADDED count — that is what actually goes through the folder.
|
|
80
|
+
*/
|
|
81
|
+
declare function bookletSizeWarning(pageCount: number): string | null;
|
|
82
|
+
/**
|
|
83
|
+
* Compose the full booklet document.
|
|
84
|
+
*
|
|
85
|
+
* `pagesHtml[i]` is the already-rendered HTML for logical page i+1. Those
|
|
86
|
+
* fragments are TRUSTED and injected verbatim (they are rendered pages, and
|
|
87
|
+
* escaping them would print markup). Only the title is escaped.
|
|
88
|
+
*/
|
|
89
|
+
declare function buildBookletDocument(pagesHtml: readonly string[], opts?: BookletPrintOptions): string;
|
|
90
|
+
/** Build the booklet document and open it in a print window. */
|
|
91
|
+
declare function printBooklet(pagesHtml: readonly string[], opts?: BookletPrintOptions): PrintOutcome;
|
|
92
|
+
|
|
93
|
+
export { type BookletImposition, type BookletPrintOptions, type BookletSheet, bookletSizeWarning, buildBookletDocument, imposeBooklet, printBooklet };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { PrintOutcome } from './core.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @ai-matrx/print/booklet — saddle-stitch booklet imposition.
|
|
5
|
+
*
|
|
6
|
+
* The education white space: no test-prep / worksheet tool offers "turn this
|
|
7
|
+
* into a fold-and-staple booklet". This module is the whole capability —
|
|
8
|
+
* the imposition math, the size guidance, and a 2-up landscape print lane
|
|
9
|
+
* composed over ./core.
|
|
10
|
+
*
|
|
11
|
+
* Zero dependencies: pure arithmetic plus string composition. Import-inert —
|
|
12
|
+
* `window` is touched only inside printBooklet, at call time.
|
|
13
|
+
*
|
|
14
|
+
* ── THE MATH ────────────────────────────────────────────────────────────────
|
|
15
|
+
* A saddle-stitch booklet is a stack of sheets folded in half together and
|
|
16
|
+
* stapled through the spine. Every sheet carries FOUR logical pages (two per
|
|
17
|
+
* side), so the page count is padded up to a multiple of 4 with blanks.
|
|
18
|
+
*
|
|
19
|
+
* For sheet i (1-indexed, i = 1..N/4) of an N-page (padded) booklet:
|
|
20
|
+
* front spread = [N − 2i + 2, 2i − 1] (left, right)
|
|
21
|
+
* back spread = [2i, N − 2i + 1] (left, right)
|
|
22
|
+
*
|
|
23
|
+
* Worked anchor, N = 8:
|
|
24
|
+
* sheet 1 → front [8, 1] back [2, 7]
|
|
25
|
+
* sheet 2 → front [6, 3] back [4, 5]
|
|
26
|
+
* Every spread's two page numbers sum to N + 1 — the invariant that makes the
|
|
27
|
+
* fold work, and the cheapest way to check an imposition is right.
|
|
28
|
+
*
|
|
29
|
+
* ── THE DUPLEX TRAP ─────────────────────────────────────────────────────────
|
|
30
|
+
* These spreads are 2-up LANDSCAPE. Ordinary duplex printing flips on the LONG
|
|
31
|
+
* edge, which for a landscape sheet means the back side lands upside-down
|
|
32
|
+
* relative to the front — the booklet folds into gibberish. There are exactly
|
|
33
|
+
* two fixes: instruct the human to select short-edge (tumble) duplex, or
|
|
34
|
+
* pre-rotate every back side 180°. A print-window lane cannot pre-rotate
|
|
35
|
+
* reliably (browsers differ on how `@page`/transform interact with duplex), so
|
|
36
|
+
* THIS lane instructs. `BookletImposition.requiresShortEdgeDuplex` exposes the
|
|
37
|
+
* trap in the type system so a future PDF lane — which owns its own page
|
|
38
|
+
* transforms — can take the pre-rotate branch instead of the instruction.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One physical sheet of paper. Each tuple is [left, right] as the reader sees
|
|
43
|
+
* that side laid flat, in 1-based logical page numbers.
|
|
44
|
+
* `0` means a blank pad page (a slot past the end of the supplied content).
|
|
45
|
+
*/
|
|
46
|
+
interface BookletSheet {
|
|
47
|
+
front: [number, number];
|
|
48
|
+
back: [number, number];
|
|
49
|
+
}
|
|
50
|
+
interface BookletImposition {
|
|
51
|
+
/** Supplied page count rounded UP to the next multiple of 4. */
|
|
52
|
+
paddedPageCount: number;
|
|
53
|
+
/** Sheets in print order: sheet 1 outermost, last sheet at the fold. */
|
|
54
|
+
sheets: BookletSheet[];
|
|
55
|
+
/**
|
|
56
|
+
* true when ordinary long-edge duplex would flip the backs — consumer must
|
|
57
|
+
* either instruct short-edge/tumble duplex or pre-rotate backs 180° (PDF
|
|
58
|
+
* lanes). Always true for this 2-up landscape imposition.
|
|
59
|
+
*/
|
|
60
|
+
requiresShortEdgeDuplex: true;
|
|
61
|
+
}
|
|
62
|
+
interface BookletPrintOptions {
|
|
63
|
+
title?: string;
|
|
64
|
+
/** CSS injected after the layout styles — style the caller's page content. */
|
|
65
|
+
pageStyles?: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Impose `pageCount` logical pages onto saddle-stitch sheets.
|
|
69
|
+
*
|
|
70
|
+
* Padding is appended at the END of the logical page run, so pad slots always
|
|
71
|
+
* carry the HIGHEST page numbers — which is why they surface as `0` in the
|
|
72
|
+
* spreads nearest the front cover (N=1 → sheet 1 front [0, 1], back [0, 0]).
|
|
73
|
+
*
|
|
74
|
+
* @throws RangeError when pageCount is not a positive integer.
|
|
75
|
+
*/
|
|
76
|
+
declare function imposeBooklet(pageCount: number): BookletImposition;
|
|
77
|
+
/**
|
|
78
|
+
* Human warning when a booklet is too fat to saddle-stitch well, else null.
|
|
79
|
+
* Measured on the PADDED count — that is what actually goes through the folder.
|
|
80
|
+
*/
|
|
81
|
+
declare function bookletSizeWarning(pageCount: number): string | null;
|
|
82
|
+
/**
|
|
83
|
+
* Compose the full booklet document.
|
|
84
|
+
*
|
|
85
|
+
* `pagesHtml[i]` is the already-rendered HTML for logical page i+1. Those
|
|
86
|
+
* fragments are TRUSTED and injected verbatim (they are rendered pages, and
|
|
87
|
+
* escaping them would print markup). Only the title is escaped.
|
|
88
|
+
*/
|
|
89
|
+
declare function buildBookletDocument(pagesHtml: readonly string[], opts?: BookletPrintOptions): string;
|
|
90
|
+
/** Build the booklet document and open it in a print window. */
|
|
91
|
+
declare function printBooklet(pagesHtml: readonly string[], opts?: BookletPrintOptions): PrintOutcome;
|
|
92
|
+
|
|
93
|
+
export { type BookletImposition, type BookletPrintOptions, type BookletSheet, bookletSizeWarning, buildBookletDocument, imposeBooklet, printBooklet };
|