pdf.ts

9.8 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 */1516import { PDFDocument, type PDFFont, type PDFPage, rgb, StandardFonts } from "pdf-lib";17import {18  CODES_PER_COL,19  CODES_PER_PAGE,20  CODES_PER_ROW,21  CodexError,22  type CodexManifest,23} from "../../shared/codex-types";2425// ── Layout constants (A4 in points; 1pt = 1/72 inch) ────────────────────────2627const PAGE_WIDTH = 595.28;28const PAGE_HEIGHT = 841.89;29const MARGIN = 28;3031const HEADER_HEIGHT = 44;32const HEADER_HAIRLINE = 0.5;3334const CODE_SIZE = 80;35const COL_GAP =36  (PAGE_WIDTH - 2 * MARGIN - CODES_PER_ROW * CODE_SIZE) / (CODES_PER_ROW - 1);37const ROW_GAP = 18;3839const RECOVERY_HEIGHT = 110;4041const FOOTER_HEIGHT = 32;42const FOOTER_HAIRLINE = 0.5;4344const CREAM = rgb(0.961, 0.941, 0.91);45const INK = rgb(0.102, 0.094, 0.086);46const AMBER_PRINT = rgb(0.545, 0.369, 0.165);4748const ROMAN_NUMERALS = [49  "I.", "II.", "III.", "IV.", "V.", "VI.",50  "VII.", "VIII.", "IX.", "X.", "XI.", "XII.",51];5253/**54 * Render the full A4 Codex PDF. Returns the serialized PDF bytes ready to be55 * blob-downloaded or printed.56 *57 * `aztecPngs` is one PNG per code, in chunk-index order. `recoveryWords` is58 * either 12 words (rendered on the last page) or `null` (no recovery block).59 */60export async function renderCodexPDF(61  aztecPngs: Uint8Array[],62  manifest: CodexManifest,63  recoveryWords: string[] | null,64): Promise<Uint8Array> {65  if (aztecPngs.length !== manifest.totalChunks) {66    throw new CodexError(67      "PDF_GENERATION_FAILED",68      `PDF render: expected ${manifest.totalChunks} Aztec images, got ${aztecPngs.length}`,69    );70  }71  if (recoveryWords && recoveryWords.length !== 12) {72    throw new CodexError(73      "PDF_GENERATION_FAILED",74      `PDF render: recovery words must be exactly 12, got ${recoveryWords.length}`,75    );76  }77  void CODES_PER_COL; // Imported for spec parity; the row math derives the column count.7879  const pdfDoc = await PDFDocument.create();8081  // Use Times-Italic for the brand mark and Courier for verify codes / labels.82  // The standard 14 fonts cover everything we need without embedding overhead.83  const serif = await pdfDoc.embedFont(StandardFonts.TimesRomanItalic);84  const mono = await pdfDoc.embedFont(StandardFonts.Courier);85  const monoBold = await pdfDoc.embedFont(StandardFonts.CourierBold);8687  const embeddedImages = await Promise.all(aztecPngs.map((bytes) => pdfDoc.embedPng(bytes)));8889  const pageCount = Math.ceil(manifest.totalChunks / CODES_PER_PAGE);9091  for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {92    const page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);93    const isLastPage = pageIndex === pageCount - 1;94    const startIndex = pageIndex * CODES_PER_PAGE;95    const pageImages = embeddedImages.slice(startIndex, startIndex + CODES_PER_PAGE);9697    drawBackground(page);98    drawBorder(page);99    drawHeader(page, manifest, pageIndex, pageCount, serif, mono, monoBold);100101    const gridTop = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - 16;102    drawAztecGrid(page, pageImages, startIndex, gridTop, mono);103104    if (isLastPage && recoveryWords) {105      const recoveryTop = MARGIN + FOOTER_HEIGHT + FOOTER_HAIRLINE + 8;106      drawRecoveryWords(page, recoveryWords, recoveryTop, serif, mono);107    }108109    drawFooter(page, manifest, serif, mono);110  }111112  try {113    return await pdfDoc.save();114  } catch (err) {115    throw new CodexError(116      "PDF_GENERATION_FAILED",117      `pdf-lib save() failed: ${err instanceof Error ? err.message : String(err)}`,118    );119  }120}121122// ── Page chrome ─────────────────────────────────────────────────────────────123124function drawBackground(page: PDFPage): void {125  page.drawRectangle({ x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT, color: CREAM });126}127128function drawBorder(page: PDFPage): void {129  page.drawRectangle({130    x: MARGIN - 6,131    y: MARGIN - 6,132    width: PAGE_WIDTH - 2 * (MARGIN - 6),133    height: PAGE_HEIGHT - 2 * (MARGIN - 6),134    borderColor: INK,135    borderOpacity: 0.2,136    borderWidth: 0.75,137  });138}139140function drawHeader(141  page: PDFPage,142  manifest: CodexManifest,143  pageIndex: number,144  pageCount: number,145  serif: PDFFont,146  mono: PDFFont,147  monoBold: PDFFont,148): void {149  const y = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT + 14;150151  // The standard 14 PDF fonts use WinAnsi encoding which omits ●. Use the152  // middle-dot bullet (U+00B7), which renders close enough to the spec's153  // filled-circle peach mark for printed output.154  page.drawText("· peach codex", { x: MARGIN, y, size: 13, font: serif, color: INK });155156  const pageText = `Page ${pageIndex + 1} of ${pageCount}`;157  const pageTextWidth = mono.widthOfTextAtSize(pageText, 9);158  page.drawText(pageText, {159    x: (PAGE_WIDTH - pageTextWidth) / 2,160    y,161    size: 9,162    font: mono,163    color: INK,164    opacity: 0.5,165  });166167  const verifyText = manifest.verifyCode;168  const verifyWidth = monoBold.widthOfTextAtSize(verifyText, 10);169  page.drawText(verifyText, {170    x: PAGE_WIDTH - MARGIN - verifyWidth,171    y,172    size: 10,173    font: monoBold,174    color: INK,175  });176177  page.drawLine({178    start: { x: MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },179    end: { x: PAGE_WIDTH - MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },180    thickness: HEADER_HAIRLINE,181    color: INK,182    opacity: 0.2,183  });184}185186// ── Aztec grid ──────────────────────────────────────────────────────────────187188/**189 * The `images` parameter is a slice of pre-embedded PDFImage objects for190 * THIS page only. `startIndex` is the global chunk index of `images[0]` and191 * is used solely to label each code with its 1-based number.192 */193function drawAztecGrid(194  page: PDFPage,195  images: import("pdf-lib").PDFImage[],196  startIndex: number,197  gridTop: number,198  mono: PDFFont,199): void {200  for (let i = 0; i < images.length; i++) {201    const col = i % CODES_PER_ROW;202    const row = Math.floor(i / CODES_PER_ROW);203    const x = MARGIN + col * (CODE_SIZE + COL_GAP);204    const y = gridTop - row * (CODE_SIZE + ROW_GAP + 14) - CODE_SIZE;205206    page.drawImage(images[i]!, { x, y, width: CODE_SIZE, height: CODE_SIZE });207208    const label = String(startIndex + i + 1).padStart(2, "0");209    const labelWidth = mono.widthOfTextAtSize(label, 8);210    page.drawText(label, {211      x: x + CODE_SIZE / 2 - labelWidth / 2,212      y: y - 11,213      size: 8,214      font: mono,215      color: INK,216      opacity: 0.55,217    });218  }219}220221// ── Recovery words ──────────────────────────────────────────────────────────222223function drawRecoveryWords(224  page: PDFPage,225  words: string[],226  bottomY: number,227  serif: PDFFont,228  mono: PDFFont,229): void {230  const top = bottomY + RECOVERY_HEIGHT;231232  page.drawLine({233    start: { x: MARGIN, y: top + 8 },234    end: { x: PAGE_WIDTH - MARGIN, y: top + 8 },235    thickness: 0.5,236    color: INK,237    opacity: 0.15,238  });239240  // pdf-lib's drawText doesn't support character spacing — render the label241  // with a visually-equivalent space-separated form so we get the loose tracking242  // the design calls for without breaking strict typings.243  page.drawText("R E C O V E R Y   W O R D S", {244    x: MARGIN,245    y: top - 4,246    size: 8,247    font: mono,248    color: INK,249    opacity: 0.4,250  });251252  const colWidth = (PAGE_WIDTH - 2 * MARGIN) / 2;253  const lineHeight = 14;254  for (let i = 0; i < 12; i++) {255    const col = Math.floor(i / 6);256    const row = i % 6;257    const wordX = MARGIN + col * colWidth;258    const wordY = top - 20 - row * lineHeight;259260    page.drawText(ROMAN_NUMERALS[i]!, { x: wordX, y: wordY, size: 10, font: serif, color: AMBER_PRINT });261    page.drawText(words[i]!, { x: wordX + 28, y: wordY, size: 10, font: serif, color: INK });262  }263}264265// ── Footer ──────────────────────────────────────────────────────────────────266267function drawFooter(268  page: PDFPage,269  manifest: CodexManifest,270  serif: PDFFont,271  mono: PDFFont,272): void {273  page.drawLine({274    start: { x: MARGIN, y: MARGIN + FOOTER_HEIGHT },275    end: { x: PAGE_WIDTH - MARGIN, y: MARGIN + FOOTER_HEIGHT },276    thickness: FOOTER_HAIRLINE,277    color: INK,278    opacity: 0.15,279  });280281  const footerY = MARGIN + 10;282283  page.drawText("This document restores your Peach vault. Keep it private. Keep it safe.", {284    x: MARGIN,285    y: footerY + 10,286    size: 7,287    font: serif,288    color: INK,289    opacity: 0.45,290  });291292  const date = new Date(manifest.generatedAt).toISOString().slice(0, 10);293  const meta = `${manifest.entryCount} entries · ${date} · ${manifest.verifyCode}`;294  const metaWidth = mono.widthOfTextAtSize(meta, 7);295  page.drawText(meta, {296    x: PAGE_WIDTH - MARGIN - metaWidth,297    y: footerY + 10,298    size: 7,299    font: mono,300    color: INK,301    opacity: 0.45,302  });303304  const siteText = "peachpasswords.com";305  const siteWidth = serif.widthOfTextAtSize(siteText, 7);306  page.drawText(siteText, {307    x: (PAGE_WIDTH - siteWidth) / 2,308    y: footerY,309    size: 7,310    font: serif,311    color: INK,312    opacity: 0.3,313  });314}