/** * pdf.ts — render the printable Codex PDF. * * Layout follows section "PDF rendering" of the design doc. The input is a * flat list of per-Aztec PNG byte arrays (one PNG per code, not one per page). * We compute pagination from `CODES_PER_PAGE` and embed each PNG into the * grid. Each PNG is embedded once via `pdfDoc.embedPng()` — pdf-lib reuses * the embedded image object across pages automatically, so even multi-page * Codexes stay compact in file size. * * Fonts: standard 14, available in every PDF viewer (no font embedding cost). * The cream/ink palette is intentionally low-contrast so the printed page * reads like an artifact rather than a tax form. * * Recovery words never appear in the PDF — they are paper-only, kept * physically separate from the Codex by design (see peach_codex_addendum.md). */ import { PDFDocument, type PDFFont, type PDFPage, rgb, StandardFonts } from "pdf-lib"; import { CODES_PER_COL, CODES_PER_PAGE, CODES_PER_ROW, CodexError, type CodexManifest, } from "../../shared/codex-types"; import { CODE_SIZE, COL_GAP, HEADER_GAP, HEADER_HEIGHT, MARGIN, PAGE_HEIGHT, PAGE_WIDTH, ROW_GAP, } from "./pdf-layout"; // ── Layout constants ──────────────────────────────────────────────────────── // Page geometry (A4, margins, header height, code size, gaps) is imported // from ./pdf-layout so the PDF importer can crop matching regions out of the // rendered canvas without drifting from the generator. const HEADER_HAIRLINE = 0.5; const FOOTER_HEIGHT = 32; const FOOTER_HAIRLINE = 0.5; const CREAM = rgb(0.961, 0.941, 0.91); const INK = rgb(0.102, 0.094, 0.086); /** * Render the full A4 Codex PDF. Returns the serialized PDF bytes ready to be * blob-downloaded or printed. `aztecPngs` is one PNG per code, in chunk-index * order. Recovery words are never drawn here — they are paper-only. */ export async function renderCodexPDF( aztecPngs: Uint8Array[], manifest: CodexManifest, ): Promise { if (aztecPngs.length !== manifest.totalChunks) { throw new CodexError( "PDF_GENERATION_FAILED", `PDF render: expected ${manifest.totalChunks} Aztec images, got ${aztecPngs.length}`, ); } void CODES_PER_COL; // Imported for spec parity; the row math derives the column count. const pdfDoc = await PDFDocument.create(); // Use Times-Italic for the brand mark and Courier for verify codes / labels. // The standard 14 fonts cover everything we need without embedding overhead. const serif = await pdfDoc.embedFont(StandardFonts.TimesRomanItalic); const mono = await pdfDoc.embedFont(StandardFonts.Courier); const monoBold = await pdfDoc.embedFont(StandardFonts.CourierBold); const embeddedImages = await Promise.all(aztecPngs.map((bytes) => pdfDoc.embedPng(bytes))); const pageCount = Math.ceil(manifest.totalChunks / CODES_PER_PAGE); for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) { const page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); const startIndex = pageIndex * CODES_PER_PAGE; const pageImages = embeddedImages.slice(startIndex, startIndex + CODES_PER_PAGE); drawBackground(page); drawBorder(page); drawHeader(page, manifest, pageIndex, pageCount, serif, mono, monoBold); const gridTop = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP; drawAztecGrid(page, pageImages, startIndex, gridTop, mono); drawFooter(page, manifest, serif, mono); } try { return await pdfDoc.save(); } catch (err) { throw new CodexError( "PDF_GENERATION_FAILED", `pdf-lib save() failed: ${err instanceof Error ? err.message : String(err)}`, ); } } // ── Page chrome ───────────────────────────────────────────────────────────── function drawBackground(page: PDFPage): void { page.drawRectangle({ x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT, color: CREAM }); } function drawBorder(page: PDFPage): void { page.drawRectangle({ x: MARGIN - 6, y: MARGIN - 6, width: PAGE_WIDTH - 2 * (MARGIN - 6), height: PAGE_HEIGHT - 2 * (MARGIN - 6), borderColor: INK, borderOpacity: 0.2, borderWidth: 0.75, }); } function drawHeader( page: PDFPage, manifest: CodexManifest, pageIndex: number, pageCount: number, serif: PDFFont, mono: PDFFont, monoBold: PDFFont, ): void { const y = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT + 14; // The standard 14 PDF fonts use WinAnsi encoding which omits ●. Use the // middle-dot bullet (U+00B7), which renders close enough to the spec's // filled-circle peach mark for printed output. page.drawText("· peach codex", { x: MARGIN, y, size: 13, font: serif, color: INK }); const pageText = `Page ${pageIndex + 1} of ${pageCount}`; const pageTextWidth = mono.widthOfTextAtSize(pageText, 9); page.drawText(pageText, { x: (PAGE_WIDTH - pageTextWidth) / 2, y, size: 9, font: mono, color: INK, opacity: 0.5, }); const verifyText = manifest.verifyCode; const verifyWidth = monoBold.widthOfTextAtSize(verifyText, 10); page.drawText(verifyText, { x: PAGE_WIDTH - MARGIN - verifyWidth, y, size: 10, font: monoBold, color: INK, }); page.drawLine({ start: { x: MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT }, end: { x: PAGE_WIDTH - MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT }, thickness: HEADER_HAIRLINE, color: INK, opacity: 0.2, }); } // ── Aztec grid ────────────────────────────────────────────────────────────── /** * The `images` parameter is a slice of pre-embedded PDFImage objects for * THIS page only. `startIndex` is the global chunk index of `images[0]` and * is used solely to label each code with its 1-based number. */ function drawAztecGrid( page: PDFPage, images: import("pdf-lib").PDFImage[], startIndex: number, gridTop: number, mono: PDFFont, ): void { for (let i = 0; i < images.length; i++) { const col = i % CODES_PER_ROW; const row = Math.floor(i / CODES_PER_ROW); const x = MARGIN + col * (CODE_SIZE + COL_GAP); const y = gridTop - row * (CODE_SIZE + ROW_GAP + 14) - CODE_SIZE; page.drawImage(images[i]!, { x, y, width: CODE_SIZE, height: CODE_SIZE }); const label = String(startIndex + i + 1).padStart(2, "0"); const labelWidth = mono.widthOfTextAtSize(label, 8); page.drawText(label, { x: x + CODE_SIZE / 2 - labelWidth / 2, y: y - 11, size: 8, font: mono, color: INK, opacity: 0.55, }); } } // ── Footer ────────────────────────────────────────────────────────────────── function drawFooter( page: PDFPage, manifest: CodexManifest, serif: PDFFont, mono: PDFFont, ): void { page.drawLine({ start: { x: MARGIN, y: MARGIN + FOOTER_HEIGHT }, end: { x: PAGE_WIDTH - MARGIN, y: MARGIN + FOOTER_HEIGHT }, thickness: FOOTER_HAIRLINE, color: INK, opacity: 0.15, }); const footerY = MARGIN + 10; page.drawText("This document restores your Peach vault. Keep it private. Keep it safe.", { x: MARGIN, y: footerY + 10, size: 7, font: serif, color: INK, opacity: 0.45, }); const date = new Date(manifest.generatedAt).toISOString().slice(0, 10); const meta = `${manifest.entryCount} entries · ${date} · ${manifest.verifyCode}`; const metaWidth = mono.widthOfTextAtSize(meta, 7); page.drawText(meta, { x: PAGE_WIDTH - MARGIN - metaWidth, y: footerY + 10, size: 7, font: mono, color: INK, opacity: 0.45, }); const siteText = "peachpasswords.com"; const siteWidth = serif.widthOfTextAtSize(siteText, 7); page.drawText(siteText, { x: (PAGE_WIDTH - siteWidth) / 2, y: footerY, size: 7, font: serif, color: INK, opacity: 0.3, }); }