Block Print System
Three-tier architecture for printing AI response messages, plus the reusable
printing core that other surfaces build on. The core lives in
the @ai-matrx/print npm package (extracted 2026-08-29; source aidream/apps/shared/print) — chat-specific tiers live with the conversation feature.
Architecture Overview
Tier 1 — Quick Print (prose only)
printMarkdownContent() → regex → HTML window
File: ../aidream/apps/shared/chat/src/conversation/utils/markdown-print.ts
Limitation: ignores all custom blocks
Tier 2 — Full Message (DOM screenshot)
useDomCapturePrint → html2canvas + jsPDF
File: ../aidream/apps/shared/chat/src/conversation/hooks/useDomCapturePrint.ts
Hook returns: { captureRef, isCapturing, progress, captureAsPDF, error }
isCapturing must be wired to the menu item (disabled state + label change)
Tier 3 — Per-Block (best quality, block owns its output)
Print button in each block's header → either HTML template printer or DOM capture
Dialog + hook: @ai-matrx/print/react (PrintOptionsDialog, usePrintOptions)
The BlockPrinter Interface
Source of truth: @ai-matrx/print/core (BlockPrinter, PrintVariant, PrintSetting, PrintSettings)
interface BlockPrinter {
label: string;
variants: PrintVariant[];
settings?: PrintSetting[];
print: (data: unknown, variantId?: string, settings?: PrintSettings) => void | Promise<void>;
}
interface PrintVariant { id: string; label: string; description?: string; }
// PrintSetting is a discriminated union of FOUR control types (all support
// appliesTo?: string[] — which variantIds the setting shows for):
// { type: "boolean"; id; label; description?; defaultValue }
// { type: "range"; id; label; fromId; toId; defaultFrom; defaultTo; min?;
// fromPlaceholder?; toPlaceholder? } // two number inputs; writes TWO
// settings-map keys (fromId/toId); 0 = "no bound" by convention
// { type: "number"; id; label; defaultValue; min?; max? } // single numeric input
// { type: "select"; id; label; options: {value,label}[]; defaultValue }
// // compact segmented one-of-N row
type PrintSettings = Record<string, boolean | string | number>;
If variants.length === 0 AND settings.length === 0, usePrintOptions calls print(data) immediately with no dialog. Otherwise the PrintOptionsDialog opens first (Dialog on desktop, Drawer on mobile).
Existing Printers
| Block / surface | Strategy | File |
|---|
| FlashcardsBlock | HTML template | @ai-matrx/print/flashcards (flashcardsPrinter) |
| MultipleChoiceQuiz | HTML template | components/mardown-display/blocks/quiz/quiz-printer.ts |
| MathProblemBlock | HTML template | components/mardown-display/blocks/math/math-printer.ts |
| DiagramBlock | HTML template | components/mardown-display/blocks/diagram/diagram-printer.ts |
| PD rating report | HTML template | features/legal/wc/pd-ratings/print/pd-report-printer.ts |
| Performance review report | HTML template | features/employee-performance-reviews/review-report.ts |
| QR label sheets | HTML template (registry-driven grid) | @ai-matrx/print/labels (qrLabelsPrinter) — Avery + roll stock QR labels; template registry, calibration page, LabelSheetPreview (in /react) + jsPDF lane |
| Various display blocks | DOM capture | inline in component via @ai-matrx/alchemy/operate/capture (captureDocumentPdf) + kit downloadFile |
Adding an HTML Template Printer
- Create
<block-name>-printer.ts alongside the component file.
- Import
buildPrintDocument, openPrintWindow, escapeHtml, type BlockPrinter, type PrintSettings from @ai-matrx/print/core.
- Define
variants and optional settings arrays.
- In
print(data, variantId = "default", settings?):
- Cast and guard:
if (!typed?.items?.length) { openPrintWindow(buildPrintDocument("<p>No data.</p>", ...), "fallback"); return; }
- Call
escapeHtml() on every user string.
- Read settings as:
const show = (settings?.showX ?? false) as boolean.
- In the component: call
usePrintOptions(printer, data) (from @ai-matrx/print/react), wire triggerPrint to a <Printer> button, render <PrintOptionsDialog> outside (not inside) the block wrapper.
Full examples in the package source (aidream/apps/shared/print/src/): flashcards.ts (variants + boolean settings + fit-text auto-shrink), labels.ts (all four setting types, inch-exact @page geometry, data-URI images).
Adding a DOM Capture Block
Required pattern — copy exactly:
const blockContentRef = useRef<HTMLDivElement>(null);
const [isPrinting, setIsPrinting] = useState(false);
const handlePrint = useCallback(async () => {
if (!blockContentRef.current || isPrinting) return;
setIsPrinting(true);
try {
const [{ captureDocumentPdf }, { downloadFile }] = await Promise.all([
import('@ai-matrx/alchemy/operate/capture'),
import('@ai-matrx/kit/download'),
]);
downloadFile('filename.pdf', await captureDocumentPdf(blockContentRef.current, { orientation: 'portrait' }), 'application/pdf'); // or 'landscape'
} catch (err) {
console.error('[BlockName] Print failed:', err);
} finally {
setIsPrinting(false);
}
}, [isPrinting]);
Key rules:
captureDocumentPdf(element, { orientation }) returns the PDF bytes (orientation: "landscape" | "portrait"); save them with kit downloadFile. App code never imports @ai-matrx/print/pdf directly (guard: pnpm check:alchemy-doors)
- Always
await it — missing await means errors are invisible silent rejections
- Attach
ref to the content container, not the whole block shell or a stats summary
disabled={isPrinting} on the button; show "Saving…" label while active
- If the block has fullscreen mode, add a print button in the fullscreen header too — the regular toolbar is hidden
Wiring Tier 2 isCapturing
In the assistant-message component:
const { captureRef, isCapturing, captureAsPDF } = useDomCapturePrint();
Pass isCapturing to the message options menu as a prop. The menu item should be disabled and show "Generating PDF…" while true.
Key Utilities
All in @ai-matrx/print (/core, /pdf, /markdown, /react). Since 0.3.0 the markdown converter and the print stylesheet ship IN the package — there is no host seam, and re-adding one is the defect this repo already made three times:
buildPrintDocument(bodyHtml, title?, extraStyles?) → complete <!DOCTYPE html> string
openPrintWindow(htmlDoc, filename?) → popup window; falls back to .html download if popup blocked. The window is a fresh unauthenticated document — inline every image as a data URI, never fetch.
printHtmlContent(bodyHtml, title?, extraStyles?) → shorthand combo
markdownToPdfBlob(markdown) (/pdf) → styled multi-page PDF Blob, no arguments beyond the markdown
markdownToHtml / getMarkdownStylesheet / printMarkdown (/markdown) → the shipped converter, the two default stylesheets ("document" and "article"), and the one-call print window. Brand it with tokens; never fork the sheet.
captureDocumentPdf(el, opts) (@ai-matrx/alchemy/operate/capture) → loads print's DOM-capture engine and returns the PDF bytes
- Fixed-geometry sheets (
@page { size: …; margin: 0 }, inch-exact cells, screen-only "100% scale, no margins" banner, FIT_TEXT auto-shrink): patterns in the package's flashcards.ts (Avery 5388) and, generalized to a template registry, labels.ts
Common Bugs to Watch For
- Missing
await on captureDocumentPdf — errors silently swallowed
ref on wrong element — attach to content, not stats/header/outer shell
- Print button absent in fullscreen — must add to fullscreen header explicitly
- Missing
settings param in print() — use settings?.key ?? defaultValue pattern
isCapturing not wired — users get no feedback during Tier 2 PDF generation
- Fetched images in a print window — the window has no auth; only data URIs render