pdf.ts

8.1 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";27import {28  CODE_SIZE,29  COL_GAP,30  HEADER_GAP,31  HEADER_HEIGHT,32  MARGIN,33  PAGE_HEIGHT,34  PAGE_WIDTH,35  ROW_GAP,36} from "./pdf-layout";3738// ── Layout constants ────────────────────────────────────────────────────────39// Page geometry (A4, margins, header height, code size, gaps) is imported40// from ./pdf-layout so the PDF importer can crop matching regions out of the41// rendered canvas without drifting from the generator.4243const HEADER_HAIRLINE = 0.5;4445const FOOTER_HEIGHT = 32;46const FOOTER_HAIRLINE = 0.5;4748const CREAM = rgb(0.961, 0.941, 0.91);49const INK = rgb(0.102, 0.094, 0.086);5051/**52 * Render the full A4 Codex PDF. Returns the serialized PDF bytes ready to be53 * blob-downloaded or printed. `aztecPngs` is one PNG per code, in chunk-index54 * order. Recovery words are never drawn here — they are paper-only.55 */56export async function renderCodexPDF(57  aztecPngs: Uint8Array[],58  manifest: CodexManifest,59): Promise<Uint8Array> {60  if (aztecPngs.length !== manifest.totalChunks) {61    throw new CodexError(62      "PDF_GENERATION_FAILED",63      `PDF render: expected ${manifest.totalChunks} Aztec images, got ${aztecPngs.length}`,64    );65  }66  void CODES_PER_COL; // Imported for spec parity; the row math derives the column count.6768  const pdfDoc = await PDFDocument.create();6970  // Use Times-Italic for the brand mark and Courier for verify codes / labels.71  // The standard 14 fonts cover everything we need without embedding overhead.72  const serif = await pdfDoc.embedFont(StandardFonts.TimesRomanItalic);73  const mono = await pdfDoc.embedFont(StandardFonts.Courier);74  const monoBold = await pdfDoc.embedFont(StandardFonts.CourierBold);7576  const embeddedImages = await Promise.all(aztecPngs.map((bytes) => pdfDoc.embedPng(bytes)));7778  const pageCount = Math.ceil(manifest.totalChunks / CODES_PER_PAGE);7980  for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {81    const page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);82    const startIndex = pageIndex * CODES_PER_PAGE;83    const pageImages = embeddedImages.slice(startIndex, startIndex + CODES_PER_PAGE);8485    drawBackground(page);86    drawBorder(page);87    drawHeader(page, manifest, pageIndex, pageCount, serif, mono, monoBold);8889    const gridTop = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;90    drawAztecGrid(page, pageImages, startIndex, gridTop, mono);9192    drawFooter(page, manifest, serif, mono);93  }9495  try {96    return await pdfDoc.save();97  } catch (err) {98    throw new CodexError(99      "PDF_GENERATION_FAILED",100      `pdf-lib save() failed: ${err instanceof Error ? err.message : String(err)}`,101    );102  }103}104105// ── Page chrome ─────────────────────────────────────────────────────────────106107function drawBackground(page: PDFPage): void {108  page.drawRectangle({ x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT, color: CREAM });109}110111function drawBorder(page: PDFPage): void {112  page.drawRectangle({113    x: MARGIN - 6,114    y: MARGIN - 6,115    width: PAGE_WIDTH - 2 * (MARGIN - 6),116    height: PAGE_HEIGHT - 2 * (MARGIN - 6),117    borderColor: INK,118    borderOpacity: 0.2,119    borderWidth: 0.75,120  });121}122123function drawHeader(124  page: PDFPage,125  manifest: CodexManifest,126  pageIndex: number,127  pageCount: number,128  serif: PDFFont,129  mono: PDFFont,130  monoBold: PDFFont,131): void {132  const y = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT + 14;133134  // The standard 14 PDF fonts use WinAnsi encoding which omits ●. Use the135  // middle-dot bullet (U+00B7), which renders close enough to the spec's136  // filled-circle peach mark for printed output.137  page.drawText("· peach codex", { x: MARGIN, y, size: 13, font: serif, color: INK });138139  const pageText = `Page ${pageIndex + 1} of ${pageCount}`;140  const pageTextWidth = mono.widthOfTextAtSize(pageText, 9);141  page.drawText(pageText, {142    x: (PAGE_WIDTH - pageTextWidth) / 2,143    y,144    size: 9,145    font: mono,146    color: INK,147    opacity: 0.5,148  });149150  const verifyText = manifest.verifyCode;151  const verifyWidth = monoBold.widthOfTextAtSize(verifyText, 10);152  page.drawText(verifyText, {153    x: PAGE_WIDTH - MARGIN - verifyWidth,154    y,155    size: 10,156    font: monoBold,157    color: INK,158  });159160  page.drawLine({161    start: { x: MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },162    end: { x: PAGE_WIDTH - MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },163    thickness: HEADER_HAIRLINE,164    color: INK,165    opacity: 0.2,166  });167}168169// ── Aztec grid ──────────────────────────────────────────────────────────────170171/**172 * The `images` parameter is a slice of pre-embedded PDFImage objects for173 * THIS page only. `startIndex` is the global chunk index of `images[0]` and174 * is used solely to label each code with its 1-based number.175 */176function drawAztecGrid(177  page: PDFPage,178  images: import("pdf-lib").PDFImage[],179  startIndex: number,180  gridTop: number,181  mono: PDFFont,182): void {183  for (let i = 0; i < images.length; i++) {184    const col = i % CODES_PER_ROW;185    const row = Math.floor(i / CODES_PER_ROW);186    const x = MARGIN + col * (CODE_SIZE + COL_GAP);187    const y = gridTop - row * (CODE_SIZE + ROW_GAP + 14) - CODE_SIZE;188189    page.drawImage(images[i]!, { x, y, width: CODE_SIZE, height: CODE_SIZE });190191    const label = String(startIndex + i + 1).padStart(2, "0");192    const labelWidth = mono.widthOfTextAtSize(label, 8);193    page.drawText(label, {194      x: x + CODE_SIZE / 2 - labelWidth / 2,195      y: y - 11,196      size: 8,197      font: mono,198      color: INK,199      opacity: 0.55,200    });201  }202}203204// ── Footer ──────────────────────────────────────────────────────────────────205206function drawFooter(207  page: PDFPage,208  manifest: CodexManifest,209  serif: PDFFont,210  mono: PDFFont,211): void {212  page.drawLine({213    start: { x: MARGIN, y: MARGIN + FOOTER_HEIGHT },214    end: { x: PAGE_WIDTH - MARGIN, y: MARGIN + FOOTER_HEIGHT },215    thickness: FOOTER_HAIRLINE,216    color: INK,217    opacity: 0.15,218  });219220  const footerY = MARGIN + 10;221222  page.drawText("This document restores your Peach vault. Keep it private. Keep it safe.", {223    x: MARGIN,224    y: footerY + 10,225    size: 7,226    font: serif,227    color: INK,228    opacity: 0.45,229  });230231  const date = new Date(manifest.generatedAt).toISOString().slice(0, 10);232  const meta = `${manifest.entryCount} entries · ${date} · ${manifest.verifyCode}`;233  const metaWidth = mono.widthOfTextAtSize(meta, 7);234  page.drawText(meta, {235    x: PAGE_WIDTH - MARGIN - metaWidth,236    y: footerY + 10,237    size: 7,238    font: mono,239    color: INK,240    opacity: 0.45,241  });242243  const siteText = "peachpasswords.com";244  const siteWidth = serif.widthOfTextAtSize(siteText, 7);245  page.drawText(siteText, {246    x: (PAGE_WIDTH - siteWidth) / 2,247    y: footerY,248    size: 7,249    font: serif,250    color: INK,251    opacity: 0.3,252  });253}