12345678910111213141516171819import { 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";37383940414243const 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);50515253545556export 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; 6768 const pdfDoc = await PDFDocument.create();6970 71 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}104105106107function 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 135 136 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}168169170171172173174175176function 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}203204205206function 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}