@ai-matrx/print 0.2.0 → 0.3.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 +107 -0
- package/README.md +44 -2
- package/dist/booklet.cjs +6 -5
- package/dist/booklet.cjs.map +1 -1
- package/dist/booklet.d.cts +4 -1
- package/dist/booklet.d.ts +4 -1
- package/dist/booklet.js +6 -5
- package/dist/booklet.js.map +1 -1
- 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 +3 -2
- package/dist/index.d.ts +3 -2
- 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/markdown.cjs +1260 -0
- package/dist/markdown.cjs.map +1 -0
- package/dist/markdown.d.cts +177 -0
- package/dist/markdown.d.ts +177 -0
- package/dist/markdown.js +1237 -0
- package/dist/markdown.js.map +1 -0
- package/dist/pdf.cjs +77 -4
- package/dist/pdf.cjs.map +1 -1
- package/dist/pdf.d.cts +35 -9
- package/dist/pdf.d.ts +35 -9
- package/dist/pdf.js +77 -4
- package/dist/pdf.js.map +1 -1
- 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 +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,112 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-08-30
|
|
4
|
+
|
|
5
|
+
**The BYO-converter seam is gone.** `markdownToPdfBlob` used to declare its
|
|
6
|
+
markdown→HTML converter and its stylesheet as REQUIRED injected ports —
|
|
7
|
+
"bring your own converter + stylesheet". That is exactly the failure THE
|
|
8
|
+
ALL-INCLUSIVE LAW names: the package shipped the easy half and left the hard
|
|
9
|
+
half to every host. Both ports now ship a complete working default; the
|
|
10
|
+
injection seam survives as the OVERRIDE path.
|
|
11
|
+
|
|
12
|
+
- **NEW `/markdown` entry** — the shipped defaults:
|
|
13
|
+
- `markdownToHtml` — the full `.matrx-*` converter (headings, emphasis,
|
|
14
|
+
nested lists at any depth, markdown tables, images, links with underscores,
|
|
15
|
+
code fences, blockquotes, intro-paragraph and FAQ promotion,
|
|
16
|
+
`<flashcards>` blocks).
|
|
17
|
+
- `markdownToArticleHtml` — bare semantic tags for the print-window look;
|
|
18
|
+
code fences are HTML-escaped (a print window does not re-escape).
|
|
19
|
+
- `removeThinkingContent` / `hasThinkingContent` — strips `<thinking>`,
|
|
20
|
+
`<think>` AND `<reasoning>`.
|
|
21
|
+
- `MARKDOWN_DOCUMENT_CSS` / `MARKDOWN_ARTICLE_CSS` + their token sheets, and
|
|
22
|
+
`getMarkdownStylesheet({ skin, tokens, extraCss })`.
|
|
23
|
+
- `renderMarkdownDocument` — markdown → a complete standalone HTML page.
|
|
24
|
+
- `printMarkdown` — markdown → print window in one call, returning the
|
|
25
|
+
`PrintOutcome` so a popup-blocked download is still announced.
|
|
26
|
+
- **`markdownToPdfBlob(markdown)` now works with no second argument.**
|
|
27
|
+
`MarkdownPdfDeps` is all-optional and gains `skin`, `tokens`, `onNotice`,
|
|
28
|
+
`imageTimeoutMs`. Overriding one port leaves the other on its default.
|
|
29
|
+
- **C26 styling** — every colour in both shipped sheets is
|
|
30
|
+
`var(--matrx-print-<role>, <default>)` over one role vocabulary; the inline
|
|
31
|
+
fallbacks make the drop-in test pass unthemed and `tokens` is appended last
|
|
32
|
+
so a host's brand wins. A test proves zero hardcoded colours survive and that
|
|
33
|
+
the token sheet defines exactly the roles the CSS consumes.
|
|
34
|
+
- **Defect found in the ported original, fixed here** — the document
|
|
35
|
+
stylesheet had NO print rules at all: it was authored as a WordPress *screen*
|
|
36
|
+
sheet and then reused for PDF/print, so tables and code blocks split across
|
|
37
|
+
page boundaries, headings stranded at page bottoms, and coloured chrome
|
|
38
|
+
dropped out. It now carries `@media print` with page-break avoidance,
|
|
39
|
+
orphans/widows and `print-color-adjust: exact`.
|
|
40
|
+
- **Nothing fails silently (C22)** — a flashcard missing `Front:`/`Back:` was
|
|
41
|
+
dropped without a word; markdown that converts to an empty document produced
|
|
42
|
+
a blank PDF; an image that 404s or never loads is painted as a blank
|
|
43
|
+
rectangle by html2canvas with no error anywhere; a rasterization failure
|
|
44
|
+
surfaced as a raw html2canvas message. All four now report with a remedy —
|
|
45
|
+
through `onNotice`, or `console.warn` when no sink is supplied. Images are
|
|
46
|
+
awaited before capture (`imageTimeoutMs`, default 8000).
|
|
47
|
+
- **Bundle** — the defaults sit behind a lazy
|
|
48
|
+
`import("@ai-matrx/print/markdown")`, and tsup now treats the package's own
|
|
49
|
+
subpaths as `external` (with `splitting: false` a relative dynamic import is
|
|
50
|
+
inlined and the lazy edge silently disappears — measured: `dist/pdf.js` fell
|
|
51
|
+
from 54.7KB to 12.7KB once the edge was real). A DOM-capture-only consumer
|
|
52
|
+
pays nothing for the stylesheets.
|
|
53
|
+
- **tests** — new `markdown.test.ts` (converter fidelity, the C26 token
|
|
54
|
+
contract, skins/overrides, the announce-what-you-drop cases, the
|
|
55
|
+
popup-blocked print fallback) plus new `markdownToPdfBlob` cases for the
|
|
56
|
+
default path, partial overrides, image notices and capture-failure wording.
|
|
57
|
+
The tarball canary exercises `/markdown` through both `import` and
|
|
58
|
+
`require`, including the no-hardcoded-colour assertion.
|
|
59
|
+
|
|
60
|
+
### Consumer action (C28)
|
|
61
|
+
|
|
62
|
+
- `markdownToPdfBlob(markdown, deps)` is **source-compatible** — existing
|
|
63
|
+
injecting call sites keep working unchanged.
|
|
64
|
+
- **Delete your markdown converter and print stylesheet.** If a host module
|
|
65
|
+
exists only to fill the old required ports, it is now redundant: call
|
|
66
|
+
`markdownToPdfBlob(markdown)` directly. matrx-frontend removed
|
|
67
|
+
`lib/print/markdown-pdf.ts`, `features/html-pages/css/wordpress-styles.ts`,
|
|
68
|
+
`features/html-pages/utils/markdown-wordpress-utils.ts`'s converter, the
|
|
69
|
+
`removeThinkingContent`/`hasThinkingContent` bodies in
|
|
70
|
+
`components/matrx/buttons/markdown-copy-utils.ts`, and the private converter
|
|
71
|
+
+ print sheet inside `features/conversation/utils/markdown-print.ts` in the
|
|
72
|
+
0.3.0 adoption.
|
|
73
|
+
- **Behaviour delta:** printed/exported document-skin output now obeys print
|
|
74
|
+
page-break rules it previously ignored. Pages break in better places; a
|
|
75
|
+
pixel-diff against a pre-0.3.0 PDF will differ.
|
|
76
|
+
- **Brand tokens:** a host that wants its own colours passes
|
|
77
|
+
`tokens: ":root{ --matrx-print-accent: …; }"` instead of forking the sheet.
|
|
78
|
+
|
|
79
|
+
## 0.2.1 — 2026-08-30
|
|
80
|
+
|
|
81
|
+
QA pass F5/F6/F8 hardening — the popup-blocked lane is now observable and has
|
|
82
|
+
one implementation. (The QA "silent no-output" reports themselves reproduced
|
|
83
|
+
as environment: with `window.open` blocked the fallback download DID fire on
|
|
84
|
+
every surface, but sandboxed automation swallowed the download. Verified by
|
|
85
|
+
live spies on chat, flashcards, and commerce labels.)
|
|
86
|
+
|
|
87
|
+
- **/core** — `openPrintWindow` / `printHtmlContent` now return a
|
|
88
|
+
`PrintOutcome` (`"opened" | "downloaded"`) so hosts can tell the user when
|
|
89
|
+
the popup was blocked and the .html download fallback fired — a silent
|
|
90
|
+
fallback is indistinguishable from failure, which is exactly how QA filed
|
|
91
|
+
it. `BlockPrinter.print` may now resolve to a `PrintOutcome` (void stays
|
|
92
|
+
legal). `openPrintWindow` gains an optional `windowFeatures` third argument.
|
|
93
|
+
The fallback's object URL is revoked on a macrotask instead of synchronously
|
|
94
|
+
(a synchronous revoke races the download start in some engines).
|
|
95
|
+
- **/flashcards** — the landscape / Avery 5388 / portrait 6-up lanes had three
|
|
96
|
+
verbatim inline copies of the popup-or-download logic; they now delegate to
|
|
97
|
+
the ONE `openPrintWindow` (same window features, same fallback filenames,
|
|
98
|
+
byte-identical documents) and return its outcome.
|
|
99
|
+
- **/labels** — `printQrLabelSheet` / `printCalibrationSheet` return the
|
|
100
|
+
outcome.
|
|
101
|
+
- **/booklet** — `printBooklet` returns the outcome.
|
|
102
|
+
- **/react** — `PrintOptionsDialog` and `usePrintOptions` accept an optional
|
|
103
|
+
`onPrinted(outcome)` callback so hosts can toast the downloaded-fallback
|
|
104
|
+
case.
|
|
105
|
+
- **tests** — new `core.test.ts` pins the popup-blocked contract at the
|
|
106
|
+
source (blocked → anchor download of the full document, sanitized
|
|
107
|
+
`<filename>.html`, cleanup, deferred revoke, `"downloaded"` outcome);
|
|
108
|
+
labels gains the same regression for `printQrLabelSheet`.
|
|
109
|
+
|
|
3
110
|
## 0.2.0 — 2026-08-30
|
|
4
111
|
|
|
5
112
|
Wave 2 — three new entries, all import-inert and RSC-safe:
|
package/README.md
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
The AI Matrx print system — everything a browser app needs to turn structured
|
|
4
4
|
content into paper: a tiny print contract, print-window composition, QR
|
|
5
5
|
generation with e-commerce payload builders, label-sheet printing on Avery and
|
|
6
|
-
roll stock with custom label formats, a full flashcard deck printer,
|
|
6
|
+
roll stock with custom label formats, a full flashcard deck printer, a shipped
|
|
7
|
+
markdown converter with two default print stylesheets, and
|
|
7
8
|
DOM-capture/markdown → PDF.
|
|
8
9
|
|
|
9
10
|
Every entry is import-inert and safe to import during SSR — browser APIs are
|
|
@@ -109,15 +110,56 @@ ranges, cut tick marks / full cut lines, side labels, card numbers, and
|
|
|
109
110
|
show-through countermeasures (gray back text, mirrored back text). Face
|
|
110
111
|
images print from durable URLs only.
|
|
111
112
|
|
|
113
|
+
### `@ai-matrx/print/markdown` — the shipped converter & print stylesheets
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { markdownToHtml, getMarkdownStylesheet, renderMarkdownDocument, printMarkdown, removeThinkingContent } from "@ai-matrx/print/markdown";
|
|
117
|
+
|
|
118
|
+
printMarkdown(markdown, { title: "Report" }); // markdown → print window, one call
|
|
119
|
+
const html = markdownToHtml(markdown); // → .matrx-* classed body HTML
|
|
120
|
+
const css = getMarkdownStylesheet({ skin: "article" }); // → the matching stylesheet
|
|
121
|
+
const page = renderMarkdownDocument(markdown, { title: "Report" }); // → a complete <!DOCTYPE html> page
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Two skins off one vocabulary. **`"document"`** (default) emits `.matrx-*`
|
|
125
|
+
classed HTML — headings, nested lists, markdown tables, images, links, code,
|
|
126
|
+
FAQ promotion, `<flashcards>` blocks — inside `.matrx-content-container`, and
|
|
127
|
+
its stylesheet is what pastes into a WordPress theme. **`"article"`** emits
|
|
128
|
+
bare semantic tags with the serif AI-response look for print windows (code
|
|
129
|
+
fences are HTML-escaped there, because a print window does not re-escape).
|
|
130
|
+
`removeThinkingContent` strips `<thinking>` / `<think>` / `<reasoning>` — all
|
|
131
|
+
three, which is why a model switching tag names cannot leak reasoning into a
|
|
132
|
+
printed page.
|
|
133
|
+
|
|
134
|
+
Colours are `var(--matrx-print-<role>, <default>)` throughout (C26): the inline
|
|
135
|
+
fallbacks make a fresh app render correctly with zero setup, and a host brands
|
|
136
|
+
it by passing `tokens: ":root{--matrx-print-accent:#0f766e}"` — appended last,
|
|
137
|
+
so the host wins. Both sheets carry the print hard parts (`@media print`,
|
|
138
|
+
page-break avoidance, orphans/widows, `print-color-adjust`).
|
|
139
|
+
|
|
112
140
|
### `@ai-matrx/print/pdf` — DOM capture & markdown → PDF
|
|
113
141
|
|
|
114
142
|
```ts
|
|
115
143
|
import { captureToPDF, captureElementsToPDF, markdownToPdfBlob } from "@ai-matrx/print/pdf";
|
|
116
144
|
|
|
117
145
|
await captureToPDF(document.getElementById("report")!, { filename: "report", orientation: "landscape" });
|
|
118
|
-
const blob = await markdownToPdfBlob(markdown
|
|
146
|
+
const blob = await markdownToPdfBlob(markdown); // converter + stylesheet included
|
|
147
|
+
const branded = await markdownToPdfBlob(markdown, { skin: "article", tokens }); // tune without replacing
|
|
148
|
+
const custom = await markdownToPdfBlob(markdown, { convertToHtml, loadCss }); // override either port
|
|
119
149
|
```
|
|
120
150
|
|
|
151
|
+
`markdownToPdfBlob` needs nothing from you. Both ports ship a working default
|
|
152
|
+
(they were REQUIRED injections until 0.3.0); supplying one replaces that
|
|
153
|
+
default and leaves the other alone. The defaults load through a lazy
|
|
154
|
+
`import("@ai-matrx/print/markdown")`, so a consumer that only does DOM capture
|
|
155
|
+
never pulls in the stylesheets.
|
|
156
|
+
|
|
157
|
+
Nothing degrades silently: a flashcard missing its `Front:`/`Back:`, markdown
|
|
158
|
+
that converts to an empty document, an image that 404s or never finishes
|
|
159
|
+
loading (html2canvas would paint a blank rectangle and say nothing), and a
|
|
160
|
+
failed rasterization all report through `onNotice` — or `console.warn` with the
|
|
161
|
+
remedy when no sink is given. `imageTimeoutMs` (default 8000) bounds the wait.
|
|
162
|
+
|
|
121
163
|
html2canvas + jsPDF, lazy-loaded on use. Handles the Tailwind v4 problem
|
|
122
164
|
(oklch/lab colors html2canvas can't parse) via a scoped `getComputedStyle`
|
|
123
165
|
patch that is always restored. `captureElementsToPDF` renders deliberately
|
package/dist/booklet.cjs
CHANGED
|
@@ -31,8 +31,8 @@ module.exports = __toCommonJS(booklet_exports);
|
|
|
31
31
|
function escapeHtml(str) {
|
|
32
32
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
33
33
|
}
|
|
34
|
-
function openPrintWindow(htmlDocument, filename = "print") {
|
|
35
|
-
const win = window.open("", "_blank",
|
|
34
|
+
function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "width=920,height=720,scrollbars=yes") {
|
|
35
|
+
const win = window.open("", "_blank", windowFeatures);
|
|
36
36
|
if (!win) {
|
|
37
37
|
const blob = new Blob([htmlDocument], { type: "text/html;charset=utf-8" });
|
|
38
38
|
const url = URL.createObjectURL(blob);
|
|
@@ -42,12 +42,13 @@ function openPrintWindow(htmlDocument, filename = "print") {
|
|
|
42
42
|
document.body.appendChild(a);
|
|
43
43
|
a.click();
|
|
44
44
|
document.body.removeChild(a);
|
|
45
|
-
URL.revokeObjectURL(url);
|
|
46
|
-
return;
|
|
45
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
46
|
+
return "downloaded";
|
|
47
47
|
}
|
|
48
48
|
win.document.open();
|
|
49
49
|
win.document.write(htmlDocument);
|
|
50
50
|
win.document.close();
|
|
51
|
+
return "opened";
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
// src/booklet.ts
|
|
@@ -197,6 +198,6 @@ ${sides}
|
|
|
197
198
|
}
|
|
198
199
|
function printBooklet(pagesHtml, opts) {
|
|
199
200
|
const title = opts?.title ?? "Booklet";
|
|
200
|
-
openPrintWindow(buildBookletDocument(pagesHtml, opts), title);
|
|
201
|
+
return openPrintWindow(buildBookletDocument(pagesHtml, opts), title);
|
|
201
202
|
}
|
|
202
203
|
//# sourceMappingURL=booklet.cjs.map
|
package/dist/booklet.cjs.map
CHANGED
|
@@ -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 } 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): void {\n const title = opts?.title ?? \"Booklet\";\n 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 print: (\n data: unknown,\n variantId?: string,\n settings?: PrintSettings,\n ) => void | Promise<void>;\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 * 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 */\nexport function openPrintWindow(\n htmlDocument: string,\n filename = \"print\",\n): void {\n const win = window.open(\"\", \"_blank\", \"width=920,height=720,scrollbars=yes\");\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 URL.revokeObjectURL(url);\n return;\n }\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n}\n\n/**\n * Shorthand: build the document and open it in one call.\n */\nexport function printHtmlContent(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): void {\n const doc = buildPrintDocument(bodyHtml, title, extraStyles);\n 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 { 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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;AC4MA,SAAS,WAAW,KAAqB;AACvC,SAAO,IACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAC1B;AAsDO,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;;;ADxNA,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,iCAAiC,WAAW,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,WAKE,WAAW,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":[]}
|
package/dist/booklet.d.cts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PrintOutcome } from './core.cjs';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @ai-matrx/print/booklet — saddle-stitch booklet imposition.
|
|
3
5
|
*
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
* trap in the type system so a future PDF lane — which owns its own page
|
|
36
38
|
* transforms — can take the pre-rotate branch instead of the instruction.
|
|
37
39
|
*/
|
|
40
|
+
|
|
38
41
|
/**
|
|
39
42
|
* One physical sheet of paper. Each tuple is [left, right] as the reader sees
|
|
40
43
|
* that side laid flat, in 1-based logical page numbers.
|
|
@@ -85,6 +88,6 @@ declare function bookletSizeWarning(pageCount: number): string | null;
|
|
|
85
88
|
*/
|
|
86
89
|
declare function buildBookletDocument(pagesHtml: readonly string[], opts?: BookletPrintOptions): string;
|
|
87
90
|
/** Build the booklet document and open it in a print window. */
|
|
88
|
-
declare function printBooklet(pagesHtml: readonly string[], opts?: BookletPrintOptions):
|
|
91
|
+
declare function printBooklet(pagesHtml: readonly string[], opts?: BookletPrintOptions): PrintOutcome;
|
|
89
92
|
|
|
90
93
|
export { type BookletImposition, type BookletPrintOptions, type BookletSheet, bookletSizeWarning, buildBookletDocument, imposeBooklet, printBooklet };
|
package/dist/booklet.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { PrintOutcome } from './core.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* @ai-matrx/print/booklet — saddle-stitch booklet imposition.
|
|
3
5
|
*
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
* trap in the type system so a future PDF lane — which owns its own page
|
|
36
38
|
* transforms — can take the pre-rotate branch instead of the instruction.
|
|
37
39
|
*/
|
|
40
|
+
|
|
38
41
|
/**
|
|
39
42
|
* One physical sheet of paper. Each tuple is [left, right] as the reader sees
|
|
40
43
|
* that side laid flat, in 1-based logical page numbers.
|
|
@@ -85,6 +88,6 @@ declare function bookletSizeWarning(pageCount: number): string | null;
|
|
|
85
88
|
*/
|
|
86
89
|
declare function buildBookletDocument(pagesHtml: readonly string[], opts?: BookletPrintOptions): string;
|
|
87
90
|
/** Build the booklet document and open it in a print window. */
|
|
88
|
-
declare function printBooklet(pagesHtml: readonly string[], opts?: BookletPrintOptions):
|
|
91
|
+
declare function printBooklet(pagesHtml: readonly string[], opts?: BookletPrintOptions): PrintOutcome;
|
|
89
92
|
|
|
90
93
|
export { type BookletImposition, type BookletPrintOptions, type BookletSheet, bookletSizeWarning, buildBookletDocument, imposeBooklet, printBooklet };
|
package/dist/booklet.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
function escapeHtml(str) {
|
|
3
3
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
4
4
|
}
|
|
5
|
-
function openPrintWindow(htmlDocument, filename = "print") {
|
|
6
|
-
const win = window.open("", "_blank",
|
|
5
|
+
function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "width=920,height=720,scrollbars=yes") {
|
|
6
|
+
const win = window.open("", "_blank", windowFeatures);
|
|
7
7
|
if (!win) {
|
|
8
8
|
const blob = new Blob([htmlDocument], { type: "text/html;charset=utf-8" });
|
|
9
9
|
const url = URL.createObjectURL(blob);
|
|
@@ -13,12 +13,13 @@ function openPrintWindow(htmlDocument, filename = "print") {
|
|
|
13
13
|
document.body.appendChild(a);
|
|
14
14
|
a.click();
|
|
15
15
|
document.body.removeChild(a);
|
|
16
|
-
URL.revokeObjectURL(url);
|
|
17
|
-
return;
|
|
16
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
17
|
+
return "downloaded";
|
|
18
18
|
}
|
|
19
19
|
win.document.open();
|
|
20
20
|
win.document.write(htmlDocument);
|
|
21
21
|
win.document.close();
|
|
22
|
+
return "opened";
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
// src/booklet.ts
|
|
@@ -168,7 +169,7 @@ ${sides}
|
|
|
168
169
|
}
|
|
169
170
|
function printBooklet(pagesHtml, opts) {
|
|
170
171
|
const title = opts?.title ?? "Booklet";
|
|
171
|
-
openPrintWindow(buildBookletDocument(pagesHtml, opts), title);
|
|
172
|
+
return openPrintWindow(buildBookletDocument(pagesHtml, opts), title);
|
|
172
173
|
}
|
|
173
174
|
export {
|
|
174
175
|
bookletSizeWarning,
|
package/dist/booklet.js.map
CHANGED
|
@@ -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 print: (\n data: unknown,\n variantId?: string,\n settings?: PrintSettings,\n ) => void | Promise<void>;\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 * 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 */\nexport function openPrintWindow(\n htmlDocument: string,\n filename = \"print\",\n): void {\n const win = window.open(\"\", \"_blank\", \"width=920,height=720,scrollbars=yes\");\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 URL.revokeObjectURL(url);\n return;\n }\n win.document.open();\n win.document.write(htmlDocument);\n win.document.close();\n}\n\n/**\n * Shorthand: build the document and open it in one call.\n */\nexport function printHtmlContent(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): void {\n const doc = buildPrintDocument(bodyHtml, title, extraStyles);\n 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 } 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): void {\n const title = opts?.title ?? \"Booklet\";\n 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// ─── 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","/**\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":[]}
|
package/dist/core.cjs
CHANGED
|
@@ -123,8 +123,8 @@ ${bodyHtml}
|
|
|
123
123
|
</body>
|
|
124
124
|
</html>`;
|
|
125
125
|
}
|
|
126
|
-
function openPrintWindow(htmlDocument, filename = "print") {
|
|
127
|
-
const win = window.open("", "_blank",
|
|
126
|
+
function openPrintWindow(htmlDocument, filename = "print", windowFeatures = "width=920,height=720,scrollbars=yes") {
|
|
127
|
+
const win = window.open("", "_blank", windowFeatures);
|
|
128
128
|
if (!win) {
|
|
129
129
|
const blob = new Blob([htmlDocument], { type: "text/html;charset=utf-8" });
|
|
130
130
|
const url = URL.createObjectURL(blob);
|
|
@@ -134,16 +134,17 @@ function openPrintWindow(htmlDocument, filename = "print") {
|
|
|
134
134
|
document.body.appendChild(a);
|
|
135
135
|
a.click();
|
|
136
136
|
document.body.removeChild(a);
|
|
137
|
-
URL.revokeObjectURL(url);
|
|
138
|
-
return;
|
|
137
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
138
|
+
return "downloaded";
|
|
139
139
|
}
|
|
140
140
|
win.document.open();
|
|
141
141
|
win.document.write(htmlDocument);
|
|
142
142
|
win.document.close();
|
|
143
|
+
return "opened";
|
|
143
144
|
}
|
|
144
145
|
function printHtmlContent(bodyHtml, title = "AI Response", extraStyles = "") {
|
|
145
146
|
const doc = buildPrintDocument(bodyHtml, title, extraStyles);
|
|
146
|
-
openPrintWindow(doc, title);
|
|
147
|
+
return openPrintWindow(doc, title);
|
|
147
148
|
}
|
|
148
149
|
function buildFitTextScript(selectors) {
|
|
149
150
|
const selectorsJson = JSON.stringify([...selectors]);
|