pdf.ts

8.2 KB · raw

1/**2 * pdf.ts — render the printable Codex PDF.3 *4 * Layout follows section "PDF rendering" of the design doc. The input is a5 * flat list of per-Aztec PNG byte arrays (one PNG per code, not one per page).6 * We compute pagination from `CODES_PER_PAGE` and embed each PNG into the7 * grid. Each PNG is embedded once via `pdfDoc.embedPng()` — pdf-lib reuses8 * the embedded image object across pages automatically, so even multi-page9 * Codexes stay compact in file size.10 *11 * Fonts: standard 14, available in every PDF viewer (no font embedding cost).12 * The cream/ink palette is intentionally low-contrast so the printed page13 * reads like an artifact rather than a tax form.14 *15 * Recovery words never appear in the PDF — they are paper-only, kept16 * physically separate from the Codex by design (see peach_codex_addendum.md).17 */1819import { PDFDocument, type PDFFont, type PDFPage, rgb, StandardFonts } from "pdf-lib";20import {21  CODES_PER_COL,22  CODES_PER_PAGE,23  CODES_PER_ROW,24  CodexError,25  type CodexManifest,26} from "../../shared/codex-types";2728// ── Layout constants (A4 in points; 1pt = 1/72 inch) ────────────────────────2930const PAGE_WIDTH = 595.28;31const PAGE_HEIGHT = 841.89;32const MARGIN = 28;3334const HEADER_HEIGHT = 44;35const HEADER_HAIRLINE = 0.5;3637// 6×6 = 36 codes per page. CODE_SIZE held at 80pt to preserve the proven38// ~0.24mm-per-module print density; the 6-row stack clears the footer with39// ~42pt of breathing room below the last label. Shrinking CODE_SIZE to gain40// page-count would erode print→scan reliability — explicitly not the41// tradeoff this surface should make.42const CODE_SIZE = 80;43const COL_GAP =44  (PAGE_WIDTH - 2 * MARGIN - CODES_PER_ROW * CODE_SIZE) / (CODES_PER_ROW - 1);45const ROW_GAP = 18;4647const FOOTER_HEIGHT = 32;48const FOOTER_HAIRLINE = 0.5;4950const CREAM = rgb(0.961, 0.941, 0.91);51const INK = rgb(0.102, 0.094, 0.086);5253/**54 * Render the full A4 Codex PDF. Returns the serialized PDF bytes ready to be55 * blob-downloaded or printed. `aztecPngs` is one PNG per code, in chunk-index56 * order. Recovery words are never drawn here — they are paper-only.57 */58export async function renderCodexPDF(59  aztecPngs: Uint8Array[],60  manifest: CodexManifest,61): Promise<Uint8Array> {62  if (aztecPngs.length !== manifest.totalChunks) {63    throw new CodexError(64      "PDF_GENERATION_FAILED",65      `PDF render: expected ${manifest.totalChunks} Aztec images, got ${aztecPngs.length}`,66    );67  }68  void CODES_PER_COL; // Imported for spec parity; the row math derives the column count.6970  const pdfDoc = await PDFDocument.create();7172  // Use Times-Italic for the brand mark and Courier for verify codes / labels.73  // The standard 14 fonts cover everything we need without embedding overhead.74  const serif = await pdfDoc.embedFont(StandardFonts.TimesRomanItalic);75  const mono = await pdfDoc.embedFont(StandardFonts.Courier);76  const monoBold = await pdfDoc.embedFont(StandardFonts.CourierBold);7778  const embeddedImages = await Promise.all(aztecPngs.map((bytes) => pdfDoc.embedPng(bytes)));7980  const pageCount = Math.ceil(manifest.totalChunks / CODES_PER_PAGE);8182  for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {83    const page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);84    const startIndex = pageIndex * CODES_PER_PAGE;85    const pageImages = embeddedImages.slice(startIndex, startIndex + CODES_PER_PAGE);8687    drawBackground(page);88    drawBorder(page);89    drawHeader(page, manifest, pageIndex, pageCount, serif, mono, monoBold);9091    const gridTop = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - 16;92    drawAztecGrid(page, pageImages, startIndex, gridTop, mono);9394    drawFooter(page, manifest, serif, mono);95  }9697  try {98    return await pdfDoc.save();99  } catch (err) {100    throw new CodexError(101      "PDF_GENERATION_FAILED",102      `pdf-lib save() failed: ${err instanceof Error ? err.message : String(err)}`,103    );104  }105}106107// ── Page chrome ─────────────────────────────────────────────────────────────108109function drawBackground(page: PDFPage): void {110  page.drawRectangle({ x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT, color: CREAM });111}112113function drawBorder(page: PDFPage): void {114  page.drawRectangle({115    x: MARGIN - 6,116    y: MARGIN - 6,117    width: PAGE_WIDTH - 2 * (MARGIN - 6),118    height: PAGE_HEIGHT - 2 * (MARGIN - 6),119    borderColor: INK,120    borderOpacity: 0.2,121    borderWidth: 0.75,122  });123}124125function drawHeader(126  page: PDFPage,127  manifest: CodexManifest,128  pageIndex: number,129  pageCount: number,130  serif: PDFFont,131  mono: PDFFont,132  monoBold: PDFFont,133): void {134  const y = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT + 14;135136  // The standard 14 PDF fonts use WinAnsi encoding which omits ●. Use the137  // middle-dot bullet (U+00B7), which renders close enough to the spec's138  // filled-circle peach mark for printed output.139  page.drawText("· peach codex", { x: MARGIN, y, size: 13, font: serif, color: INK });140141  const pageText = `Page ${pageIndex + 1} of ${pageCount}`;142  const pageTextWidth = mono.widthOfTextAtSize(pageText, 9);143  page.drawText(pageText, {144    x: (PAGE_WIDTH - pageTextWidth) / 2,145    y,146    size: 9,147    font: mono,148    color: INK,149    opacity: 0.5,150  });151152  const verifyText = manifest.verifyCode;153  const verifyWidth = monoBold.widthOfTextAtSize(verifyText, 10);154  page.drawText(verifyText, {155    x: PAGE_WIDTH - MARGIN - verifyWidth,156    y,157    size: 10,158    font: monoBold,159    color: INK,160  });161162  page.drawLine({163    start: { x: MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },164    end: { x: PAGE_WIDTH - MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },165    thickness: HEADER_HAIRLINE,166    color: INK,167    opacity: 0.2,168  });169}170171// ── Aztec grid ──────────────────────────────────────────────────────────────172173/**174 * The `images` parameter is a slice of pre-embedded PDFImage objects for175 * THIS page only. `startIndex` is the global chunk index of `images[0]` and176 * is used solely to label each code with its 1-based number.177 */178function drawAztecGrid(179  page: PDFPage,180  images: import("pdf-lib").PDFImage[],181  startIndex: number,182  gridTop: number,183  mono: PDFFont,184): void {185  for (let i = 0; i < images.length; i++) {186    const col = i % CODES_PER_ROW;187    const row = Math.floor(i / CODES_PER_ROW);188    const x = MARGIN + col * (CODE_SIZE + COL_GAP);189    const y = gridTop - row * (CODE_SIZE + ROW_GAP + 14) - CODE_SIZE;190191    page.drawImage(images[i]!, { x, y, width: CODE_SIZE, height: CODE_SIZE });192193    const label = String(startIndex + i + 1).padStart(2, "0");194    const labelWidth = mono.widthOfTextAtSize(label, 8);195    page.drawText(label, {196      x: x + CODE_SIZE / 2 - labelWidth / 2,197      y: y - 11,198      size: 8,199      font: mono,200      color: INK,201      opacity: 0.55,202    });203  }204}205206// ── Footer ──────────────────────────────────────────────────────────────────207208function drawFooter(209  page: PDFPage,210  manifest: CodexManifest,211  serif: PDFFont,212  mono: PDFFont,213): void {214  page.drawLine({215    start: { x: MARGIN, y: MARGIN + FOOTER_HEIGHT },216    end: { x: PAGE_WIDTH - MARGIN, y: MARGIN + FOOTER_HEIGHT },217    thickness: FOOTER_HAIRLINE,218    color: INK,219    opacity: 0.15,220  });221222  const footerY = MARGIN + 10;223224  page.drawText("This document restores your Peach vault. Keep it private. Keep it safe.", {225    x: MARGIN,226    y: footerY + 10,227    size: 7,228    font: serif,229    color: INK,230    opacity: 0.45,231  });232233  const date = new Date(manifest.generatedAt).toISOString().slice(0, 10);234  const meta = `${manifest.entryCount} entries · ${date} · ${manifest.verifyCode}`;235  const metaWidth = mono.widthOfTextAtSize(meta, 7);236  page.drawText(meta, {237    x: PAGE_WIDTH - MARGIN - metaWidth,238    y: footerY + 10,239    size: 7,240    font: mono,241    color: INK,242    opacity: 0.45,243  });244245  const siteText = "peachpasswords.com";246  const siteWidth = serif.widthOfTextAtSize(siteText, 7);247  page.drawText(siteText, {248    x: (PAGE_WIDTH - siteWidth) / 2,249    y: footerY,250    size: 7,251    font: serif,252    color: INK,253    opacity: 0.3,254  });255}