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";27282930const PAGE_WIDTH = 595.28;31const PAGE_HEIGHT = 841.89;32const MARGIN = 28;3334const HEADER_HEIGHT = 44;35const HEADER_HAIRLINE = 0.5;36373839404142const CODE_SIZE = 80;43const COL_GAP =44 (PAGE_WIDTH - 2 * MARGIN - CODES_PER_ROW * CODE_SIZE) / (CODES_PER_ROW - 1);45const ROW_GAP = 18;4647const FOOTER_HEIGHT = 32;48const FOOTER_HAIRLINE = 0.5;4950const CREAM = rgb(0.961, 0.941, 0.91);51const INK = rgb(0.102, 0.094, 0.086);52535455565758export async function renderCodexPDF(59 aztecPngs: Uint8Array[],60 manifest: CodexManifest,61): Promise<Uint8Array> {62 if (aztecPngs.length !== manifest.totalChunks) {63 throw new CodexError(64 "PDF_GENERATION_FAILED",65 `PDF render: expected ${manifest.totalChunks} Aztec images, got ${aztecPngs.length}`,66 );67 }68 void CODES_PER_COL; 6970 const pdfDoc = await PDFDocument.create();7172 73 74 const serif = await pdfDoc.embedFont(StandardFonts.TimesRomanItalic);75 const mono = await pdfDoc.embedFont(StandardFonts.Courier);76 const monoBold = await pdfDoc.embedFont(StandardFonts.CourierBold);7778 const embeddedImages = await Promise.all(aztecPngs.map((bytes) => pdfDoc.embedPng(bytes)));7980 const pageCount = Math.ceil(manifest.totalChunks / CODES_PER_PAGE);8182 for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {83 const page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);84 const startIndex = pageIndex * CODES_PER_PAGE;85 const pageImages = embeddedImages.slice(startIndex, startIndex + CODES_PER_PAGE);8687 drawBackground(page);88 drawBorder(page);89 drawHeader(page, manifest, pageIndex, pageCount, serif, mono, monoBold);9091 const gridTop = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - 16;92 drawAztecGrid(page, pageImages, startIndex, gridTop, mono);9394 drawFooter(page, manifest, serif, mono);95 }9697 try {98 return await pdfDoc.save();99 } catch (err) {100 throw new CodexError(101 "PDF_GENERATION_FAILED",102 `pdf-lib save() failed: ${err instanceof Error ? err.message : String(err)}`,103 );104 }105}106107108109function drawBackground(page: PDFPage): void {110 page.drawRectangle({ x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT, color: CREAM });111}112113function drawBorder(page: PDFPage): void {114 page.drawRectangle({115 x: MARGIN - 6,116 y: MARGIN - 6,117 width: PAGE_WIDTH - 2 * (MARGIN - 6),118 height: PAGE_HEIGHT - 2 * (MARGIN - 6),119 borderColor: INK,120 borderOpacity: 0.2,121 borderWidth: 0.75,122 });123}124125function drawHeader(126 page: PDFPage,127 manifest: CodexManifest,128 pageIndex: number,129 pageCount: number,130 serif: PDFFont,131 mono: PDFFont,132 monoBold: PDFFont,133): void {134 const y = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT + 14;135136 137 138 139 page.drawText("· peach codex", { x: MARGIN, y, size: 13, font: serif, color: INK });140141 const pageText = `Page ${pageIndex + 1} of ${pageCount}`;142 const pageTextWidth = mono.widthOfTextAtSize(pageText, 9);143 page.drawText(pageText, {144 x: (PAGE_WIDTH - pageTextWidth) / 2,145 y,146 size: 9,147 font: mono,148 color: INK,149 opacity: 0.5,150 });151152 const verifyText = manifest.verifyCode;153 const verifyWidth = monoBold.widthOfTextAtSize(verifyText, 10);154 page.drawText(verifyText, {155 x: PAGE_WIDTH - MARGIN - verifyWidth,156 y,157 size: 10,158 font: monoBold,159 color: INK,160 });161162 page.drawLine({163 start: { x: MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },164 end: { x: PAGE_WIDTH - MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },165 thickness: HEADER_HAIRLINE,166 color: INK,167 opacity: 0.2,168 });169}170171172173174175176177178function drawAztecGrid(179 page: PDFPage,180 images: import("pdf-lib").PDFImage[],181 startIndex: number,182 gridTop: number,183 mono: PDFFont,184): void {185 for (let i = 0; i < images.length; i++) {186 const col = i % CODES_PER_ROW;187 const row = Math.floor(i / CODES_PER_ROW);188 const x = MARGIN + col * (CODE_SIZE + COL_GAP);189 const y = gridTop - row * (CODE_SIZE + ROW_GAP + 14) - CODE_SIZE;190191 page.drawImage(images[i]!, { x, y, width: CODE_SIZE, height: CODE_SIZE });192193 const label = String(startIndex + i + 1).padStart(2, "0");194 const labelWidth = mono.widthOfTextAtSize(label, 8);195 page.drawText(label, {196 x: x + CODE_SIZE / 2 - labelWidth / 2,197 y: y - 11,198 size: 8,199 font: mono,200 color: INK,201 opacity: 0.55,202 });203 }204}205206207208function drawFooter(209 page: PDFPage,210 manifest: CodexManifest,211 serif: PDFFont,212 mono: PDFFont,213): void {214 page.drawLine({215 start: { x: MARGIN, y: MARGIN + FOOTER_HEIGHT },216 end: { x: PAGE_WIDTH - MARGIN, y: MARGIN + FOOTER_HEIGHT },217 thickness: FOOTER_HAIRLINE,218 color: INK,219 opacity: 0.15,220 });221222 const footerY = MARGIN + 10;223224 page.drawText("This document restores your Peach vault. Keep it private. Keep it safe.", {225 x: MARGIN,226 y: footerY + 10,227 size: 7,228 font: serif,229 color: INK,230 opacity: 0.45,231 });232233 const date = new Date(manifest.generatedAt).toISOString().slice(0, 10);234 const meta = `${manifest.entryCount} entries · ${date} · ${manifest.verifyCode}`;235 const metaWidth = mono.widthOfTextAtSize(meta, 7);236 page.drawText(meta, {237 x: PAGE_WIDTH - MARGIN - metaWidth,238 y: footerY + 10,239 size: 7,240 font: mono,241 color: INK,242 opacity: 0.45,243 });244245 const siteText = "peachpasswords.com";246 const siteWidth = serif.widthOfTextAtSize(siteText, 7);247 page.drawText(siteText, {248 x: (PAGE_WIDTH - siteWidth) / 2,249 y: footerY,250 size: 7,251 font: serif,252 color: INK,253 opacity: 0.3,254 });255}