@ai-matrx/print 0.1.0 → 0.2.0

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.
@@ -0,0 +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, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\")\n .replace(/'/g, \"&#39;\");\n}\n\n/**\n * Wrap body HTML in a complete, styled print document.\n */\nexport function buildPrintDocument(\n bodyHtml: string,\n title = \"AI Response\",\n extraStyles = \"\",\n): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>${escapeHtml(title)}</title>\n <style>\n${PRINT_STYLES}\n${extraStyles}\n </style>\n</head>\n<body>\n <div class=\"print-actions\">\n <button class=\"print-btn print-btn-primary\" onclick=\"window.print()\">Print / Save as PDF</button>\n <button class=\"print-btn print-btn-secondary\" onclick=\"window.close()\">Close</button>\n </div>\n <div class=\"content\">\n${bodyHtml}\n </div>\n</body>\n</html>`;\n}\n\n/**\n * 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 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @ai-matrx/print — root entry: TYPES ONLY.\n *\n * The runtime lives in the subpaths (each import-inert, RSC-safe except\n * /react which is a client entry):\n * @ai-matrx/print/core BlockPrinter contract + print-window composition\n * @ai-matrx/print/qr QR generation + e-commerce payload builders\n * @ai-matrx/print/labels label-sheet engine (templates, formats, print/PDF)\n * @ai-matrx/print/flashcards the deck printer (10 variants)\n * @ai-matrx/print/pdf DOM-capture / markdown → PDF\n * @ai-matrx/print/react PrintOptionsDialog + LabelSheetPreview\n */\n\nexport type {\n BlockPrinter,\n PrintVariant,\n PrintSetting,\n PrintSettings,\n} from \"./core\";\n\nexport type { QrEcLevel, QrOptions } from \"./qr\";\n\nexport type {\n LabelTemplate,\n QrLabel,\n QrLabelPrintData,\n QrLabelSettings,\n ScannabilityReport,\n LabelField,\n LabelElement,\n LabelFormat,\n} from \"./labels\";\n\nexport type { Flashcard, FlashcardsVariant } from \"./flashcards\";\n\nexport type { DomCaptureOptions, MarkdownPdfDeps } from \"./pdf\";\n"],"mappings":";;;;;;;;;;;;;;;;AAAA;AAAA;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @ai-matrx/print — root entry: TYPES ONLY.\n *\n * The runtime lives in the subpaths (each import-inert, RSC-safe except\n * /react which is a client entry):\n * @ai-matrx/print/core BlockPrinter contract + print-window composition\n * @ai-matrx/print/qr QR generation + e-commerce payload builders\n * @ai-matrx/print/labels label-sheet engine (templates, formats, print/PDF)\n * @ai-matrx/print/flashcards the deck printer (10 variants)\n * @ai-matrx/print/pdf DOM-capture / markdown → PDF\n * @ai-matrx/print/barcode 1-D barcodes (Code128 / EAN-13 / UPC-A)\n * @ai-matrx/print/booklet saddle-stitch imposition + 2-up print lane\n * @ai-matrx/print/qr-styled branded/logo QR (browser-only at call time)\n * @ai-matrx/print/react PrintOptionsDialog + LabelSheetPreview\n */\n\nexport type {\n BlockPrinter,\n PrintVariant,\n PrintSetting,\n PrintSettings,\n} from \"./core\";\n\nexport type { QrEcLevel, QrOptions } from \"./qr\";\n\nexport type {\n LabelTemplate,\n QrLabel,\n QrLabelPrintData,\n QrLabelSettings,\n ScannabilityReport,\n LabelField,\n LabelElement,\n LabelFormat,\n} from \"./labels\";\n\nexport type { Flashcard, FlashcardsVariant } from \"./flashcards\";\n\nexport type { DomCaptureOptions, MarkdownPdfDeps } from \"./pdf\";\n\nexport type { BarcodeSymbology, BarcodeOptions } from \"./barcode\";\n\nexport type {\n BookletSheet,\n BookletImposition,\n BookletPrintOptions,\n} from \"./booklet\";\n\nexport type {\n StyledQrOptions,\n StyledQrEcLevel,\n StyledQrDotStyle,\n StyledQrCornerStyle,\n} from \"./qr-styled\";\n"],"mappings":";;;;;;;;;;;;;;;;AAAA;AAAA;","names":[]}
package/dist/index.d.cts CHANGED
@@ -3,3 +3,6 @@ export { QrEcLevel, QrOptions } from './qr.cjs';
3
3
  export { LabelElement, LabelField, LabelFormat, LabelTemplate, QrLabel, QrLabelPrintData, QrLabelSettings, ScannabilityReport } from './labels.cjs';
4
4
  export { Flashcard, FlashcardsVariant } from './flashcards.cjs';
5
5
  export { DomCaptureOptions, MarkdownPdfDeps } from './pdf.cjs';
6
+ export { BarcodeOptions, BarcodeSymbology } from './barcode.cjs';
7
+ export { BookletImposition, BookletPrintOptions, BookletSheet } from './booklet.cjs';
8
+ export { StyledQrCornerStyle, StyledQrDotStyle, StyledQrEcLevel, StyledQrOptions } from './qr-styled.cjs';
package/dist/index.d.ts CHANGED
@@ -3,3 +3,6 @@ export { QrEcLevel, QrOptions } from './qr.js';
3
3
  export { LabelElement, LabelField, LabelFormat, LabelTemplate, QrLabel, QrLabelPrintData, QrLabelSettings, ScannabilityReport } from './labels.js';
4
4
  export { Flashcard, FlashcardsVariant } from './flashcards.js';
5
5
  export { DomCaptureOptions, MarkdownPdfDeps } from './pdf.js';
6
+ export { BarcodeOptions, BarcodeSymbology } from './barcode.js';
7
+ export { BookletImposition, BookletPrintOptions, BookletSheet } from './booklet.js';
8
+ export { StyledQrCornerStyle, StyledQrDotStyle, StyledQrEcLevel, StyledQrOptions } from './qr-styled.js';
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/qr-styled.ts
31
+ var qr_styled_exports = {};
32
+ __export(qr_styled_exports, {
33
+ DEFAULT_STYLED_QR_EC_LEVEL: () => DEFAULT_STYLED_QR_EC_LEVEL,
34
+ DEFAULT_STYLED_QR_LOGO_RATIO: () => DEFAULT_STYLED_QR_LOGO_RATIO,
35
+ DEFAULT_STYLED_QR_WIDTH_PX: () => DEFAULT_STYLED_QR_WIDTH_PX,
36
+ MAX_STYLED_QR_LOGO_RATIO: () => MAX_STYLED_QR_LOGO_RATIO,
37
+ STYLED_QR_MIN_EC_LEVEL: () => STYLED_QR_MIN_EC_LEVEL,
38
+ STYLED_QR_QUIET_ZONE_RATIO: () => STYLED_QR_QUIET_ZONE_RATIO,
39
+ assertHexColor: () => assertHexColor,
40
+ assertLogoDataUrl: () => assertLogoDataUrl,
41
+ buildStyledQrLibOptions: () => buildStyledQrLibOptions,
42
+ generateStyledQrDataUrl: () => generateStyledQrDataUrl,
43
+ generateStyledQrSvg: () => generateStyledQrSvg,
44
+ resolveLogoSizeRatio: () => resolveLogoSizeRatio,
45
+ resolveStyledEcLevel: () => resolveStyledEcLevel
46
+ });
47
+ module.exports = __toCommonJS(qr_styled_exports);
48
+ var STYLED_QR_MIN_EC_LEVEL = "Q";
49
+ var DEFAULT_STYLED_QR_EC_LEVEL = "H";
50
+ var DEFAULT_STYLED_QR_WIDTH_PX = 512;
51
+ var DEFAULT_STYLED_QR_LOGO_RATIO = 0.2;
52
+ var MAX_STYLED_QR_LOGO_RATIO = 0.3;
53
+ var STYLED_QR_QUIET_ZONE_RATIO = 0.08;
54
+ var DEFAULT_STYLED_QR_COLOR = "#000000";
55
+ var DEFAULT_STYLED_QR_BACKGROUND = "#ffffff";
56
+ function assertValue(value) {
57
+ if (typeof value !== "string" || value.length === 0) {
58
+ throw new Error("Styled QR value must be a non-empty string.");
59
+ }
60
+ return value;
61
+ }
62
+ function resolveStyledEcLevel(ecLevel) {
63
+ if (ecLevel === void 0) return DEFAULT_STYLED_QR_EC_LEVEL;
64
+ if (ecLevel === "Q" || ecLevel === "H") return ecLevel;
65
+ return STYLED_QR_MIN_EC_LEVEL;
66
+ }
67
+ function resolveWidthPx(widthPx) {
68
+ if (widthPx === void 0) return DEFAULT_STYLED_QR_WIDTH_PX;
69
+ if (typeof widthPx !== "number" || !Number.isFinite(widthPx) || widthPx <= 0) {
70
+ throw new Error(`Styled QR widthPx must be a positive number, got ${String(widthPx)}.`);
71
+ }
72
+ return Math.round(widthPx);
73
+ }
74
+ function resolveLogoSizeRatio(logoSizeRatio) {
75
+ if (typeof logoSizeRatio !== "number" || !Number.isFinite(logoSizeRatio) || logoSizeRatio <= 0) {
76
+ return DEFAULT_STYLED_QR_LOGO_RATIO;
77
+ }
78
+ return Math.min(MAX_STYLED_QR_LOGO_RATIO, logoSizeRatio);
79
+ }
80
+ function assertLogoDataUrl(logoDataUrl) {
81
+ if (typeof logoDataUrl !== "string" || logoDataUrl.trim() === "") {
82
+ throw new Error("Styled QR logoDataUrl must be a non-empty string when provided.");
83
+ }
84
+ const trimmed = logoDataUrl.trim();
85
+ if (!/^data:/i.test(trimmed)) {
86
+ throw new Error(
87
+ `Styled QR logo must be a data: URL \u2014 got "${trimmed.slice(0, 48)}". DATA URL ONLY: the print window is unauthenticated so a remote image may not load at all, and a cross-origin image taints the canvas, which makes the PNG export throw. Inline the logo as a data: URL (e.g. data:image/png;base64,...) before passing it.`
88
+ );
89
+ }
90
+ return trimmed;
91
+ }
92
+ var HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
93
+ function assertHexColor(color, field) {
94
+ if (typeof color !== "string" || !HEX_COLOR.test(color.trim())) {
95
+ throw new Error(
96
+ `Styled QR ${field} must be a hex colour like "#000000" or "#fff", got "${String(color)}". Named colours, rgb() and hsl() are not accepted.`
97
+ );
98
+ }
99
+ return color.trim();
100
+ }
101
+ function buildStyledQrLibOptions(value, drawType, opts) {
102
+ assertValue(value);
103
+ const widthPx = resolveWidthPx(opts?.widthPx);
104
+ const color = opts?.color === void 0 ? DEFAULT_STYLED_QR_COLOR : assertHexColor(opts.color, "color");
105
+ const backgroundColor = opts?.backgroundColor === void 0 ? DEFAULT_STYLED_QR_BACKGROUND : assertHexColor(opts.backgroundColor, "backgroundColor");
106
+ const built = {
107
+ type: drawType,
108
+ width: widthPx,
109
+ height: widthPx,
110
+ margin: Math.round(widthPx * STYLED_QR_QUIET_ZONE_RATIO),
111
+ data: value,
112
+ qrOptions: { errorCorrectionLevel: resolveStyledEcLevel(opts?.ecLevel) },
113
+ dotsOptions: { color },
114
+ backgroundOptions: { color: backgroundColor }
115
+ };
116
+ if (opts?.dotStyle !== void 0) built.dotsOptions.type = opts.dotStyle;
117
+ if (opts?.cornerStyle !== void 0) built.cornersSquareOptions = { type: opts.cornerStyle, color };
118
+ if (opts?.logoDataUrl !== void 0) {
119
+ built.image = assertLogoDataUrl(opts.logoDataUrl);
120
+ built.imageOptions = {
121
+ imageSize: resolveLogoSizeRatio(opts.logoSizeRatio),
122
+ // Clearing the modules under the logo is what keeps the mark legible;
123
+ // the forced Q/H error correction is what pays for the hole.
124
+ hideBackgroundDots: true,
125
+ margin: 0,
126
+ // Belt and braces: the data: URL cannot taint the canvas anyway.
127
+ crossOrigin: "anonymous"
128
+ };
129
+ }
130
+ return built;
131
+ }
132
+ var BROWSER_REQUIRED = "styled QR generation requires a browser environment \u2014 use @ai-matrx/print/qr for server-side codes";
133
+ function assertBrowser() {
134
+ if (typeof document === "undefined" || typeof window === "undefined") {
135
+ throw new Error(BROWSER_REQUIRED);
136
+ }
137
+ }
138
+ async function loadQrCodeStyling() {
139
+ const mod = await import("qr-code-styling");
140
+ return mod.default;
141
+ }
142
+ function isBlobLike(raw) {
143
+ if (typeof raw !== "object" || raw === null) return false;
144
+ const candidate = raw;
145
+ return typeof candidate.slice === "function" && typeof candidate.size === "number";
146
+ }
147
+ function readBlobWith(blob, read) {
148
+ if (typeof FileReader === "undefined") {
149
+ throw new Error(BROWSER_REQUIRED);
150
+ }
151
+ return new Promise((resolve, reject) => {
152
+ const reader = new FileReader();
153
+ reader.onload = () => resolve(reader.result);
154
+ reader.onerror = () => reject(reader.error ?? new Error("Styled QR blob could not be read."));
155
+ read(reader);
156
+ });
157
+ }
158
+ async function blobToBytes(blob) {
159
+ if (typeof blob.arrayBuffer === "function") {
160
+ return new Uint8Array(await blob.arrayBuffer());
161
+ }
162
+ const result = await readBlobWith(blob, (reader) => reader.readAsArrayBuffer(blob));
163
+ if (!(result instanceof ArrayBuffer)) {
164
+ throw new Error("Styled QR blob could not be read as bytes.");
165
+ }
166
+ return new Uint8Array(result);
167
+ }
168
+ async function blobToText(blob) {
169
+ if (typeof blob.text === "function") {
170
+ return blob.text();
171
+ }
172
+ const result = await readBlobWith(blob, (reader) => reader.readAsText(blob));
173
+ if (typeof result !== "string") {
174
+ throw new Error("Styled QR blob could not be read as text.");
175
+ }
176
+ return result;
177
+ }
178
+ function bytesToBase64(bytes) {
179
+ let binary = "";
180
+ const CHUNK = 32768;
181
+ for (let i = 0; i < bytes.length; i += CHUNK) {
182
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
183
+ }
184
+ if (typeof btoa !== "function") {
185
+ throw new Error(BROWSER_REQUIRED);
186
+ }
187
+ return btoa(binary);
188
+ }
189
+ async function rawToUint8Array(raw, extension) {
190
+ if (raw === null || raw === void 0) {
191
+ throw new Error(`Styled QR ${extension} generation produced no data.`);
192
+ }
193
+ if (isBlobLike(raw)) {
194
+ return blobToBytes(raw);
195
+ }
196
+ if (raw instanceof Uint8Array) return raw;
197
+ if (raw instanceof ArrayBuffer) return new Uint8Array(raw);
198
+ throw new Error(`Styled QR ${extension} generation returned an unexpected payload type.`);
199
+ }
200
+ async function generateStyledQrDataUrl(value, opts) {
201
+ const options = buildStyledQrLibOptions(value, "canvas", opts);
202
+ assertBrowser();
203
+ const QRCodeStyling = await loadQrCodeStyling();
204
+ const raw = await new QRCodeStyling(options).getRawData("png");
205
+ const bytes = await rawToUint8Array(raw, "PNG");
206
+ return `data:image/png;base64,${bytesToBase64(bytes)}`;
207
+ }
208
+ async function generateStyledQrSvg(value, opts) {
209
+ const options = buildStyledQrLibOptions(value, "svg", opts);
210
+ assertBrowser();
211
+ const QRCodeStyling = await loadQrCodeStyling();
212
+ const raw = await new QRCodeStyling(options).getRawData("svg");
213
+ if (isBlobLike(raw)) {
214
+ return blobToText(raw);
215
+ }
216
+ const bytes = await rawToUint8Array(raw, "SVG");
217
+ return new TextDecoder().decode(bytes);
218
+ }
219
+ //# sourceMappingURL=qr-styled.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/qr-styled.ts"],"sourcesContent":["/**\n * @ai-matrx/print/qr-styled — BRANDED / STYLED QR GENERATION.\n *\n * This is the clothing-tag lane. `@ai-matrx/print/qr` is the warehouse lane: it\n * draws a plain, maximally-boring symbol anywhere JavaScript runs (Node\n * included) and is what every label, carton and dock-door code should use. This\n * module exists for the one job that one cannot do — putting a brand mark in the\n * middle of the code and colouring the modules — and it pays for that with two\n * real restrictions that are NOT style choices:\n *\n * 1. **High error correction is FORCED.** A centre logo OCCLUDES MODULES. The\n * symbol only survives because Reed–Solomon recovery covers the hole, so a\n * styled code is restricted to \"Q\" (25%) or \"H\" (30%), and defaults to \"H\".\n * A caller passing \"L\" or \"M\" is **CLAMPED UP to \"Q\"** — never thrown at,\n * mirroring the quiet-zone clamp in `qr.ts`: we would rather print a\n * slightly denser symbol than one that stops scanning the moment the logo\n * lands on it.\n * 2. **The logo is a DATA URL, or it is refused.** Print windows are\n * unauthenticated `about:blank` documents — a remote `https://` logo would\n * both fail to load behind any cookie/auth wall and TAINT THE CANVAS, at\n * which point `toDataURL()` throws a SecurityError and the whole sheet dies.\n * Non-`data:` URLs are rejected up front with an error naming the rule\n * rather than failing mysteriously at draw time.\n *\n * BROWSER-ONLY AT CALL TIME. `qr-code-styling` draws through DOM + canvas/SVG,\n * so both functions throw a clear Error in a non-DOM environment and point at\n * `@ai-matrx/print/qr` for the server-side lane. The MODULE ITSELF STAYS INERT\n * everywhere: `qr-code-styling` is only ever pulled in via `await import(...)`\n * inside a function body, so importing this file from a React Server Component\n * (or the tarball canary, which imports it in bare Node) costs nothing and\n * throws nothing. No module-level mutable state.\n */\n\n// ─── Contract ────────────────────────────────────────────────────────────────\n\n/** The only two error correction levels a styled (logo-bearing) code may use. */\nexport type StyledQrEcLevel = \"Q\" | \"H\";\n\n/** Module shapes. These are passed straight through to `dotsOptions.type`. */\nexport type StyledQrDotStyle = \"square\" | \"dots\" | \"rounded\" | \"classy\" | \"extra-rounded\";\n\n/** Finder-pattern (corner square) shapes — straight through to `cornersSquareOptions.type`. */\nexport type StyledQrCornerStyle = \"square\" | \"dot\" | \"extra-rounded\";\n\nexport interface StyledQrOptions {\n /** Error correction. Default \"H\". \"L\"/\"M\" are CLAMPED UP to \"Q\" — a logo eats modules. */\n ecLevel?: StyledQrEcLevel;\n /** Rendered width (and height) in pixels. Default 512. */\n widthPx?: number;\n /** Centre image. **DATA URL ONLY** — a remote URL is rejected (unauthenticated print window + canvas tainting). */\n logoDataUrl?: string;\n /** Logo side as a fraction of the symbol. Default 0.2, CLAMPED to <= 0.3 for scannability. */\n logoSizeRatio?: number;\n /** Module shape. Default \"square\". */\n dotStyle?: StyledQrDotStyle;\n /** Module colour. Hex only (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`). Default \"#000000\". */\n color?: string;\n /** Background colour. Same hex rule. Default \"#ffffff\". */\n backgroundColor?: string;\n /** Finder-pattern shape. Defaults to following `dotStyle`'s library default (omitted). */\n cornerStyle?: StyledQrCornerStyle;\n}\n\n/** Styled codes never drop below this. */\nexport const STYLED_QR_MIN_EC_LEVEL: StyledQrEcLevel = \"Q\";\n\n/** The default for every styled code — maximum recovery, because the logo is always eating modules. */\nexport const DEFAULT_STYLED_QR_EC_LEVEL: StyledQrEcLevel = \"H\";\n\n/** Default rendered width in pixels. */\nexport const DEFAULT_STYLED_QR_WIDTH_PX = 512;\n\n/** Default logo side as a fraction of the symbol. */\nexport const DEFAULT_STYLED_QR_LOGO_RATIO = 0.2;\n\n/**\n * Hard ceiling on the logo. Past roughly 30% of the symbol side even \"H\" (30%\n * recovery) cannot reconstruct what the image covered, so a bigger request is\n * clamped rather than honoured.\n */\nexport const MAX_STYLED_QR_LOGO_RATIO = 0.3;\n\n/**\n * Quiet zone, as a fraction of the rendered width.\n *\n * `qr-code-styling`'s `margin` is measured in PIXELS, not modules, and the\n * module count is not known until the symbol is encoded — so an exact 4-module\n * quiet zone cannot be requested up front. 8% of the width is a deliberately\n * generous approximation: for a typical version-4-to-8 symbol (33–49 modules\n * plus quiet zone) it lands at or above the ISO/IEC 18004 §6.3.8 four-module\n * minimum. Erring large costs a slightly smaller symbol inside the same box;\n * erring small costs a code that fails at the scanner.\n */\nexport const STYLED_QR_QUIET_ZONE_RATIO = 0.08;\n\nconst DEFAULT_STYLED_QR_COLOR = \"#000000\";\nconst DEFAULT_STYLED_QR_BACKGROUND = \"#ffffff\";\n\n// ─── Validation / clamps ─────────────────────────────────────────────────────\n\nfunction assertValue(value: string): string {\n if (typeof value !== \"string\" || value.length === 0) {\n throw new Error(\"Styled QR value must be a non-empty string.\");\n }\n return value;\n}\n\n/**\n * The EC clamp. Unset → \"H\". \"Q\"/\"H\" pass. ANYTHING else (including the \"L\"/\"M\"\n * a loosely-typed JS caller can still hand us) is raised to \"Q\" and never\n * throws — same posture as the quiet-zone clamp in `qr.ts`.\n */\nexport function resolveStyledEcLevel(ecLevel: StyledQrEcLevel | undefined): StyledQrEcLevel {\n if (ecLevel === undefined) return DEFAULT_STYLED_QR_EC_LEVEL;\n if (ecLevel === \"Q\" || ecLevel === \"H\") return ecLevel;\n return STYLED_QR_MIN_EC_LEVEL;\n}\n\nfunction resolveWidthPx(widthPx: number | undefined): number {\n if (widthPx === undefined) return DEFAULT_STYLED_QR_WIDTH_PX;\n if (typeof widthPx !== \"number\" || !Number.isFinite(widthPx) || widthPx <= 0) {\n throw new Error(`Styled QR widthPx must be a positive number, got ${String(widthPx)}.`);\n }\n return Math.round(widthPx);\n}\n\n/** Default 0.2; clamped to (0, 0.3]. A non-numeric/non-positive value falls back to the default. */\nexport function resolveLogoSizeRatio(logoSizeRatio: number | undefined): number {\n if (typeof logoSizeRatio !== \"number\" || !Number.isFinite(logoSizeRatio) || logoSizeRatio <= 0) {\n return DEFAULT_STYLED_QR_LOGO_RATIO;\n }\n return Math.min(MAX_STYLED_QR_LOGO_RATIO, logoSizeRatio);\n}\n\n/**\n * THE LOGO RULE: a `data:` URL, or nothing.\n *\n * Print windows are unauthenticated and canvas export refuses to serialize a\n * canvas a cross-origin image has touched, so a remote logo is not \"worse\", it\n * is broken. Rejected loudly here rather than silently at draw time.\n */\nexport function assertLogoDataUrl(logoDataUrl: string): string {\n if (typeof logoDataUrl !== \"string\" || logoDataUrl.trim() === \"\") {\n throw new Error(\"Styled QR logoDataUrl must be a non-empty string when provided.\");\n }\n const trimmed = logoDataUrl.trim();\n if (!/^data:/i.test(trimmed)) {\n throw new Error(\n `Styled QR logo must be a data: URL — got \"${trimmed.slice(0, 48)}\". ` +\n \"DATA URL ONLY: the print window is unauthenticated so a remote image may not load at all, \" +\n \"and a cross-origin image taints the canvas, which makes the PNG export throw. \" +\n \"Inline the logo as a data: URL (e.g. data:image/png;base64,...) before passing it.\",\n );\n }\n return trimmed;\n}\n\nconst HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;\n\n/** Hex only — `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`. Named/rgb()/hsl() colours are refused. */\nexport function assertHexColor(color: string, field: string): string {\n if (typeof color !== \"string\" || !HEX_COLOR.test(color.trim())) {\n throw new Error(\n `Styled QR ${field} must be a hex colour like \"#000000\" or \"#fff\", got \"${String(color)}\". ` +\n \"Named colours, rgb() and hsl() are not accepted.\",\n );\n }\n return color.trim();\n}\n\n// ─── Option assembly ─────────────────────────────────────────────────────────\n\n/**\n * The shape handed to `new QRCodeStyling(...)`. Declared structurally rather\n * than imported from `qr-code-styling`, so this module's own types never depend\n * on that package's (which pulls `jsdom`/`canvas` types in through its .d.ts).\n *\n * `dotStyle` and `cornerStyle` need NO remapping: every value in our contract is\n * spelled identically in the library's `DotType` / `CornerSquareType` unions.\n */\nexport interface StyledQrLibOptions {\n type: \"canvas\" | \"svg\";\n width: number;\n height: number;\n margin: number;\n data: string;\n image?: string;\n qrOptions: { errorCorrectionLevel: StyledQrEcLevel };\n imageOptions?: { imageSize: number; hideBackgroundDots: boolean; margin: number; crossOrigin: string };\n dotsOptions: { type?: StyledQrDotStyle; color: string };\n cornersSquareOptions?: { type: StyledQrCornerStyle; color: string };\n backgroundOptions: { color: string };\n}\n\n/**\n * Pure option builder — every clamp and every gate runs here, which is what\n * makes them testable without a canvas.\n */\nexport function buildStyledQrLibOptions(\n value: string,\n drawType: \"canvas\" | \"svg\",\n opts?: StyledQrOptions,\n): StyledQrLibOptions {\n assertValue(value);\n\n const widthPx = resolveWidthPx(opts?.widthPx);\n const color = opts?.color === undefined ? DEFAULT_STYLED_QR_COLOR : assertHexColor(opts.color, \"color\");\n const backgroundColor =\n opts?.backgroundColor === undefined\n ? DEFAULT_STYLED_QR_BACKGROUND\n : assertHexColor(opts.backgroundColor, \"backgroundColor\");\n\n const built: StyledQrLibOptions = {\n type: drawType,\n width: widthPx,\n height: widthPx,\n margin: Math.round(widthPx * STYLED_QR_QUIET_ZONE_RATIO),\n data: value,\n qrOptions: { errorCorrectionLevel: resolveStyledEcLevel(opts?.ecLevel) },\n dotsOptions: { color },\n backgroundOptions: { color: backgroundColor },\n };\n\n // exactOptionalPropertyTypes: omit keys entirely rather than assigning undefined.\n if (opts?.dotStyle !== undefined) built.dotsOptions.type = opts.dotStyle;\n if (opts?.cornerStyle !== undefined) built.cornersSquareOptions = { type: opts.cornerStyle, color };\n if (opts?.logoDataUrl !== undefined) {\n built.image = assertLogoDataUrl(opts.logoDataUrl);\n built.imageOptions = {\n imageSize: resolveLogoSizeRatio(opts.logoSizeRatio),\n // Clearing the modules under the logo is what keeps the mark legible;\n // the forced Q/H error correction is what pays for the hole.\n hideBackgroundDots: true,\n margin: 0,\n // Belt and braces: the data: URL cannot taint the canvas anyway.\n crossOrigin: \"anonymous\",\n };\n }\n\n return built;\n}\n\n// ─── Browser gate + draw ─────────────────────────────────────────────────────\n\nconst BROWSER_REQUIRED =\n \"styled QR generation requires a browser environment — use @ai-matrx/print/qr for server-side codes\";\n\nfunction assertBrowser(): void {\n if (typeof document === \"undefined\" || typeof window === \"undefined\") {\n throw new Error(BROWSER_REQUIRED);\n }\n}\n\n/** Minimal structural view of what we actually use from the library. */\ninterface QrCodeStylingInstance {\n getRawData(extension: \"png\" | \"svg\"): Promise<unknown>;\n}\ntype QrCodeStylingCtor = new (options: StyledQrLibOptions) => QrCodeStylingInstance;\n\n/** Lazy import — this is the ONLY place `qr-code-styling` is pulled in. */\nasync function loadQrCodeStyling(): Promise<QrCodeStylingCtor> {\n const mod = await import(\"qr-code-styling\");\n return mod.default as unknown as QrCodeStylingCtor;\n}\n\n/**\n * Duck-typed rather than `instanceof Blob`: the Blob `qr-code-styling` hands\n * back comes from ITS window, which is not necessarily this realm's — and, in\n * jsdom, is a Blob whose prototype carries only `slice`/`size`/`type`, with\n * neither `arrayBuffer()` nor `text()`. That is exactly why the readers below\n * fall back to `FileReader`.\n */\nfunction isBlobLike(raw: unknown): raw is Blob {\n if (typeof raw !== \"object\" || raw === null) return false;\n const candidate = raw as { slice?: unknown; size?: unknown };\n return typeof candidate.slice === \"function\" && typeof candidate.size === \"number\";\n}\n\nfunction readBlobWith(blob: Blob, read: (reader: FileReader) => void): Promise<FileReader[\"result\"]> {\n if (typeof FileReader === \"undefined\") {\n throw new Error(BROWSER_REQUIRED);\n }\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(reader.result);\n reader.onerror = () => reject(reader.error ?? new Error(\"Styled QR blob could not be read.\"));\n read(reader);\n });\n}\n\nasync function blobToBytes(blob: Blob): Promise<Uint8Array> {\n if (typeof blob.arrayBuffer === \"function\") {\n return new Uint8Array(await blob.arrayBuffer());\n }\n const result = await readBlobWith(blob, (reader) => reader.readAsArrayBuffer(blob));\n if (!(result instanceof ArrayBuffer)) {\n throw new Error(\"Styled QR blob could not be read as bytes.\");\n }\n return new Uint8Array(result);\n}\n\nasync function blobToText(blob: Blob): Promise<string> {\n if (typeof blob.text === \"function\") {\n return blob.text();\n }\n const result = await readBlobWith(blob, (reader) => reader.readAsText(blob));\n if (typeof result !== \"string\") {\n throw new Error(\"Styled QR blob could not be read as text.\");\n }\n return result;\n}\n\nfunction bytesToBase64(bytes: Uint8Array): string {\n let binary = \"\";\n const CHUNK = 0x8000;\n for (let i = 0; i < bytes.length; i += CHUNK) {\n binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));\n }\n if (typeof btoa !== \"function\") {\n throw new Error(BROWSER_REQUIRED);\n }\n return btoa(binary);\n}\n\nasync function rawToUint8Array(raw: unknown, extension: string): Promise<Uint8Array> {\n if (raw === null || raw === undefined) {\n throw new Error(`Styled QR ${extension} generation produced no data.`);\n }\n if (isBlobLike(raw)) {\n return blobToBytes(raw);\n }\n if (raw instanceof Uint8Array) return raw;\n if (raw instanceof ArrayBuffer) return new Uint8Array(raw);\n throw new Error(`Styled QR ${extension} generation returned an unexpected payload type.`);\n}\n\n/**\n * PNG data URL. BROWSER-ONLY at call time (see the module header); lazy-imports\n * `qr-code-styling` so this file stays import-inert in Node.\n */\nexport async function generateStyledQrDataUrl(value: string, opts?: StyledQrOptions): Promise<string> {\n const options = buildStyledQrLibOptions(value, \"canvas\", opts);\n assertBrowser();\n\n const QRCodeStyling = await loadQrCodeStyling();\n const raw = await new QRCodeStyling(options).getRawData(\"png\");\n const bytes = await rawToUint8Array(raw, \"PNG\");\n return `data:image/png;base64,${bytesToBase64(bytes)}`;\n}\n\n/** SVG markup string. Same clamps, same gates, same lazy import. */\nexport async function generateStyledQrSvg(value: string, opts?: StyledQrOptions): Promise<string> {\n const options = buildStyledQrLibOptions(value, \"svg\", opts);\n assertBrowser();\n\n const QRCodeStyling = await loadQrCodeStyling();\n const raw = await new QRCodeStyling(options).getRawData(\"svg\");\n if (isBlobLike(raw)) {\n return blobToText(raw);\n }\n const bytes = await rawToUint8Array(raw, \"SVG\");\n return new TextDecoder().decode(bytes);\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @ai-matrx/print/qr-styled — BRANDED / STYLED QR GENERATION.
3
+ *
4
+ * This is the clothing-tag lane. `@ai-matrx/print/qr` is the warehouse lane: it
5
+ * draws a plain, maximally-boring symbol anywhere JavaScript runs (Node
6
+ * included) and is what every label, carton and dock-door code should use. This
7
+ * module exists for the one job that one cannot do — putting a brand mark in the
8
+ * middle of the code and colouring the modules — and it pays for that with two
9
+ * real restrictions that are NOT style choices:
10
+ *
11
+ * 1. **High error correction is FORCED.** A centre logo OCCLUDES MODULES. The
12
+ * symbol only survives because Reed–Solomon recovery covers the hole, so a
13
+ * styled code is restricted to "Q" (25%) or "H" (30%), and defaults to "H".
14
+ * A caller passing "L" or "M" is **CLAMPED UP to "Q"** — never thrown at,
15
+ * mirroring the quiet-zone clamp in `qr.ts`: we would rather print a
16
+ * slightly denser symbol than one that stops scanning the moment the logo
17
+ * lands on it.
18
+ * 2. **The logo is a DATA URL, or it is refused.** Print windows are
19
+ * unauthenticated `about:blank` documents — a remote `https://` logo would
20
+ * both fail to load behind any cookie/auth wall and TAINT THE CANVAS, at
21
+ * which point `toDataURL()` throws a SecurityError and the whole sheet dies.
22
+ * Non-`data:` URLs are rejected up front with an error naming the rule
23
+ * rather than failing mysteriously at draw time.
24
+ *
25
+ * BROWSER-ONLY AT CALL TIME. `qr-code-styling` draws through DOM + canvas/SVG,
26
+ * so both functions throw a clear Error in a non-DOM environment and point at
27
+ * `@ai-matrx/print/qr` for the server-side lane. The MODULE ITSELF STAYS INERT
28
+ * everywhere: `qr-code-styling` is only ever pulled in via `await import(...)`
29
+ * inside a function body, so importing this file from a React Server Component
30
+ * (or the tarball canary, which imports it in bare Node) costs nothing and
31
+ * throws nothing. No module-level mutable state.
32
+ */
33
+ /** The only two error correction levels a styled (logo-bearing) code may use. */
34
+ type StyledQrEcLevel = "Q" | "H";
35
+ /** Module shapes. These are passed straight through to `dotsOptions.type`. */
36
+ type StyledQrDotStyle = "square" | "dots" | "rounded" | "classy" | "extra-rounded";
37
+ /** Finder-pattern (corner square) shapes — straight through to `cornersSquareOptions.type`. */
38
+ type StyledQrCornerStyle = "square" | "dot" | "extra-rounded";
39
+ interface StyledQrOptions {
40
+ /** Error correction. Default "H". "L"/"M" are CLAMPED UP to "Q" — a logo eats modules. */
41
+ ecLevel?: StyledQrEcLevel;
42
+ /** Rendered width (and height) in pixels. Default 512. */
43
+ widthPx?: number;
44
+ /** Centre image. **DATA URL ONLY** — a remote URL is rejected (unauthenticated print window + canvas tainting). */
45
+ logoDataUrl?: string;
46
+ /** Logo side as a fraction of the symbol. Default 0.2, CLAMPED to <= 0.3 for scannability. */
47
+ logoSizeRatio?: number;
48
+ /** Module shape. Default "square". */
49
+ dotStyle?: StyledQrDotStyle;
50
+ /** Module colour. Hex only (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`). Default "#000000". */
51
+ color?: string;
52
+ /** Background colour. Same hex rule. Default "#ffffff". */
53
+ backgroundColor?: string;
54
+ /** Finder-pattern shape. Defaults to following `dotStyle`'s library default (omitted). */
55
+ cornerStyle?: StyledQrCornerStyle;
56
+ }
57
+ /** Styled codes never drop below this. */
58
+ declare const STYLED_QR_MIN_EC_LEVEL: StyledQrEcLevel;
59
+ /** The default for every styled code — maximum recovery, because the logo is always eating modules. */
60
+ declare const DEFAULT_STYLED_QR_EC_LEVEL: StyledQrEcLevel;
61
+ /** Default rendered width in pixels. */
62
+ declare const DEFAULT_STYLED_QR_WIDTH_PX = 512;
63
+ /** Default logo side as a fraction of the symbol. */
64
+ declare const DEFAULT_STYLED_QR_LOGO_RATIO = 0.2;
65
+ /**
66
+ * Hard ceiling on the logo. Past roughly 30% of the symbol side even "H" (30%
67
+ * recovery) cannot reconstruct what the image covered, so a bigger request is
68
+ * clamped rather than honoured.
69
+ */
70
+ declare const MAX_STYLED_QR_LOGO_RATIO = 0.3;
71
+ /**
72
+ * Quiet zone, as a fraction of the rendered width.
73
+ *
74
+ * `qr-code-styling`'s `margin` is measured in PIXELS, not modules, and the
75
+ * module count is not known until the symbol is encoded — so an exact 4-module
76
+ * quiet zone cannot be requested up front. 8% of the width is a deliberately
77
+ * generous approximation: for a typical version-4-to-8 symbol (33–49 modules
78
+ * plus quiet zone) it lands at or above the ISO/IEC 18004 §6.3.8 four-module
79
+ * minimum. Erring large costs a slightly smaller symbol inside the same box;
80
+ * erring small costs a code that fails at the scanner.
81
+ */
82
+ declare const STYLED_QR_QUIET_ZONE_RATIO = 0.08;
83
+ /**
84
+ * The EC clamp. Unset → "H". "Q"/"H" pass. ANYTHING else (including the "L"/"M"
85
+ * a loosely-typed JS caller can still hand us) is raised to "Q" and never
86
+ * throws — same posture as the quiet-zone clamp in `qr.ts`.
87
+ */
88
+ declare function resolveStyledEcLevel(ecLevel: StyledQrEcLevel | undefined): StyledQrEcLevel;
89
+ /** Default 0.2; clamped to (0, 0.3]. A non-numeric/non-positive value falls back to the default. */
90
+ declare function resolveLogoSizeRatio(logoSizeRatio: number | undefined): number;
91
+ /**
92
+ * THE LOGO RULE: a `data:` URL, or nothing.
93
+ *
94
+ * Print windows are unauthenticated and canvas export refuses to serialize a
95
+ * canvas a cross-origin image has touched, so a remote logo is not "worse", it
96
+ * is broken. Rejected loudly here rather than silently at draw time.
97
+ */
98
+ declare function assertLogoDataUrl(logoDataUrl: string): string;
99
+ /** Hex only — `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`. Named/rgb()/hsl() colours are refused. */
100
+ declare function assertHexColor(color: string, field: string): string;
101
+ /**
102
+ * The shape handed to `new QRCodeStyling(...)`. Declared structurally rather
103
+ * than imported from `qr-code-styling`, so this module's own types never depend
104
+ * on that package's (which pulls `jsdom`/`canvas` types in through its .d.ts).
105
+ *
106
+ * `dotStyle` and `cornerStyle` need NO remapping: every value in our contract is
107
+ * spelled identically in the library's `DotType` / `CornerSquareType` unions.
108
+ */
109
+ interface StyledQrLibOptions {
110
+ type: "canvas" | "svg";
111
+ width: number;
112
+ height: number;
113
+ margin: number;
114
+ data: string;
115
+ image?: string;
116
+ qrOptions: {
117
+ errorCorrectionLevel: StyledQrEcLevel;
118
+ };
119
+ imageOptions?: {
120
+ imageSize: number;
121
+ hideBackgroundDots: boolean;
122
+ margin: number;
123
+ crossOrigin: string;
124
+ };
125
+ dotsOptions: {
126
+ type?: StyledQrDotStyle;
127
+ color: string;
128
+ };
129
+ cornersSquareOptions?: {
130
+ type: StyledQrCornerStyle;
131
+ color: string;
132
+ };
133
+ backgroundOptions: {
134
+ color: string;
135
+ };
136
+ }
137
+ /**
138
+ * Pure option builder — every clamp and every gate runs here, which is what
139
+ * makes them testable without a canvas.
140
+ */
141
+ declare function buildStyledQrLibOptions(value: string, drawType: "canvas" | "svg", opts?: StyledQrOptions): StyledQrLibOptions;
142
+ /**
143
+ * PNG data URL. BROWSER-ONLY at call time (see the module header); lazy-imports
144
+ * `qr-code-styling` so this file stays import-inert in Node.
145
+ */
146
+ declare function generateStyledQrDataUrl(value: string, opts?: StyledQrOptions): Promise<string>;
147
+ /** SVG markup string. Same clamps, same gates, same lazy import. */
148
+ declare function generateStyledQrSvg(value: string, opts?: StyledQrOptions): Promise<string>;
149
+
150
+ export { DEFAULT_STYLED_QR_EC_LEVEL, DEFAULT_STYLED_QR_LOGO_RATIO, DEFAULT_STYLED_QR_WIDTH_PX, MAX_STYLED_QR_LOGO_RATIO, STYLED_QR_MIN_EC_LEVEL, STYLED_QR_QUIET_ZONE_RATIO, type StyledQrCornerStyle, type StyledQrDotStyle, type StyledQrEcLevel, type StyledQrLibOptions, type StyledQrOptions, assertHexColor, assertLogoDataUrl, buildStyledQrLibOptions, generateStyledQrDataUrl, generateStyledQrSvg, resolveLogoSizeRatio, resolveStyledEcLevel };