pdf.ts

7.9 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;3637const CODE_SIZE = 80;38const COL_GAP =39  (PAGE_WIDTH - 2 * MARGIN - CODES_PER_ROW * CODE_SIZE) / (CODES_PER_ROW - 1);40const ROW_GAP = 18;4142const FOOTER_HEIGHT = 32;43const FOOTER_HAIRLINE = 0.5;4445const CREAM = rgb(0.961, 0.941, 0.91);46const INK = rgb(0.102, 0.094, 0.086);4748/**49 * Render the full A4 Codex PDF. Returns the serialized PDF bytes ready to be50 * blob-downloaded or printed. `aztecPngs` is one PNG per code, in chunk-index51 * order. Recovery words are never drawn here — they are paper-only.52 */53export async function renderCodexPDF(54  aztecPngs: Uint8Array[],55  manifest: CodexManifest,56): Promise<Uint8Array> {57  if (aztecPngs.length !== manifest.totalChunks) {58    throw new CodexError(59      "PDF_GENERATION_FAILED",60      `PDF render: expected ${manifest.totalChunks} Aztec images, got ${aztecPngs.length}`,61    );62  }63  void CODES_PER_COL; // Imported for spec parity; the row math derives the column count.6465  const pdfDoc = await PDFDocument.create();6667  // Use Times-Italic for the brand mark and Courier for verify codes / labels.68  // The standard 14 fonts cover everything we need without embedding overhead.69  const serif = await pdfDoc.embedFont(StandardFonts.TimesRomanItalic);70  const mono = await pdfDoc.embedFont(StandardFonts.Courier);71  const monoBold = await pdfDoc.embedFont(StandardFonts.CourierBold);7273  const embeddedImages = await Promise.all(aztecPngs.map((bytes) => pdfDoc.embedPng(bytes)));7475  const pageCount = Math.ceil(manifest.totalChunks / CODES_PER_PAGE);7677  for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {78    const page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);79    const startIndex = pageIndex * CODES_PER_PAGE;80    const pageImages = embeddedImages.slice(startIndex, startIndex + CODES_PER_PAGE);8182    drawBackground(page);83    drawBorder(page);84    drawHeader(page, manifest, pageIndex, pageCount, serif, mono, monoBold);8586    const gridTop = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - 16;87    drawAztecGrid(page, pageImages, startIndex, gridTop, mono);8889    drawFooter(page, manifest, serif, mono);90  }9192  try {93    return await pdfDoc.save();94  } catch (err) {95    throw new CodexError(96      "PDF_GENERATION_FAILED",97      `pdf-lib save() failed: ${err instanceof Error ? err.message : String(err)}`,98    );99  }100}101102// ── Page chrome ─────────────────────────────────────────────────────────────103104function drawBackground(page: PDFPage): void {105  page.drawRectangle({ x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT, color: CREAM });106}107108function drawBorder(page: PDFPage): void {109  page.drawRectangle({110    x: MARGIN - 6,111    y: MARGIN - 6,112    width: PAGE_WIDTH - 2 * (MARGIN - 6),113    height: PAGE_HEIGHT - 2 * (MARGIN - 6),114    borderColor: INK,115    borderOpacity: 0.2,116    borderWidth: 0.75,117  });118}119120function drawHeader(121  page: PDFPage,122  manifest: CodexManifest,123  pageIndex: number,124  pageCount: number,125  serif: PDFFont,126  mono: PDFFont,127  monoBold: PDFFont,128): void {129  const y = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT + 14;130131  // The standard 14 PDF fonts use WinAnsi encoding which omits ●. Use the132  // middle-dot bullet (U+00B7), which renders close enough to the spec's133  // filled-circle peach mark for printed output.134  page.drawText("· peach codex", { x: MARGIN, y, size: 13, font: serif, color: INK });135136  const pageText = `Page ${pageIndex + 1} of ${pageCount}`;137  const pageTextWidth = mono.widthOfTextAtSize(pageText, 9);138  page.drawText(pageText, {139    x: (PAGE_WIDTH - pageTextWidth) / 2,140    y,141    size: 9,142    font: mono,143    color: INK,144    opacity: 0.5,145  });146147  const verifyText = manifest.verifyCode;148  const verifyWidth = monoBold.widthOfTextAtSize(verifyText, 10);149  page.drawText(verifyText, {150    x: PAGE_WIDTH - MARGIN - verifyWidth,151    y,152    size: 10,153    font: monoBold,154    color: INK,155  });156157  page.drawLine({158    start: { x: MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },159    end: { x: PAGE_WIDTH - MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },160    thickness: HEADER_HAIRLINE,161    color: INK,162    opacity: 0.2,163  });164}165166// ── Aztec grid ──────────────────────────────────────────────────────────────167168/**169 * The `images` parameter is a slice of pre-embedded PDFImage objects for170 * THIS page only. `startIndex` is the global chunk index of `images[0]` and171 * is used solely to label each code with its 1-based number.172 */173function drawAztecGrid(174  page: PDFPage,175  images: import("pdf-lib").PDFImage[],176  startIndex: number,177  gridTop: number,178  mono: PDFFont,179): void {180  for (let i = 0; i < images.length; i++) {181    const col = i % CODES_PER_ROW;182    const row = Math.floor(i / CODES_PER_ROW);183    const x = MARGIN + col * (CODE_SIZE + COL_GAP);184    const y = gridTop - row * (CODE_SIZE + ROW_GAP + 14) - CODE_SIZE;185186    page.drawImage(images[i]!, { x, y, width: CODE_SIZE, height: CODE_SIZE });187188    const label = String(startIndex + i + 1).padStart(2, "0");189    const labelWidth = mono.widthOfTextAtSize(label, 8);190    page.drawText(label, {191      x: x + CODE_SIZE / 2 - labelWidth / 2,192      y: y - 11,193      size: 8,194      font: mono,195      color: INK,196      opacity: 0.55,197    });198  }199}200201// ── Footer ──────────────────────────────────────────────────────────────────202203function drawFooter(204  page: PDFPage,205  manifest: CodexManifest,206  serif: PDFFont,207  mono: PDFFont,208): void {209  page.drawLine({210    start: { x: MARGIN, y: MARGIN + FOOTER_HEIGHT },211    end: { x: PAGE_WIDTH - MARGIN, y: MARGIN + FOOTER_HEIGHT },212    thickness: FOOTER_HAIRLINE,213    color: INK,214    opacity: 0.15,215  });216217  const footerY = MARGIN + 10;218219  page.drawText("This document restores your Peach vault. Keep it private. Keep it safe.", {220    x: MARGIN,221    y: footerY + 10,222    size: 7,223    font: serif,224    color: INK,225    opacity: 0.45,226  });227228  const date = new Date(manifest.generatedAt).toISOString().slice(0, 10);229  const meta = `${manifest.entryCount} entries · ${date} · ${manifest.verifyCode}`;230  const metaWidth = mono.widthOfTextAtSize(meta, 7);231  page.drawText(meta, {232    x: PAGE_WIDTH - MARGIN - metaWidth,233    y: footerY + 10,234    size: 7,235    font: mono,236    color: INK,237    opacity: 0.45,238  });239240  const siteText = "peachpasswords.com";241  const siteWidth = serif.widthOfTextAtSize(siteText, 7);242  page.drawText(siteText, {243    x: (PAGE_WIDTH - siteWidth) / 2,244    y: footerY,245    size: 7,246    font: serif,247    color: INK,248    opacity: 0.3,249  });250}