12345678910111213141516import { 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";24252627const 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];525354555657585960export 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; 7879 const pdfDoc = await PDFDocument.create();8081 82 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}121122123124function 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 152 153 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}185186187188189190191192193function 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}220221222223function 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 241 242 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}264265266267function 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}