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;3637const CODE_SIZE = 80;38const COL_GAP =39 (PAGE_WIDTH - 2 * MARGIN - CODES_PER_ROW * CODE_SIZE) / (CODES_PER_ROW - 1);40const ROW_GAP = 18;4142const FOOTER_HEIGHT = 32;43const FOOTER_HAIRLINE = 0.5;4445const CREAM = rgb(0.961, 0.941, 0.91);46const INK = rgb(0.102, 0.094, 0.086);47484950515253export async function renderCodexPDF(54 aztecPngs: Uint8Array[],55 manifest: CodexManifest,56): Promise<Uint8Array> {57 if (aztecPngs.length !== manifest.totalChunks) {58 throw new CodexError(59 "PDF_GENERATION_FAILED",60 `PDF render: expected ${manifest.totalChunks} Aztec images, got ${aztecPngs.length}`,61 );62 }63 void CODES_PER_COL; 6465 const pdfDoc = await PDFDocument.create();6667 68 69 const serif = await pdfDoc.embedFont(StandardFonts.TimesRomanItalic);70 const mono = await pdfDoc.embedFont(StandardFonts.Courier);71 const monoBold = await pdfDoc.embedFont(StandardFonts.CourierBold);7273 const embeddedImages = await Promise.all(aztecPngs.map((bytes) => pdfDoc.embedPng(bytes)));7475 const pageCount = Math.ceil(manifest.totalChunks / CODES_PER_PAGE);7677 for (let pageIndex = 0; pageIndex < pageCount; pageIndex++) {78 const page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);79 const startIndex = pageIndex * CODES_PER_PAGE;80 const pageImages = embeddedImages.slice(startIndex, startIndex + CODES_PER_PAGE);8182 drawBackground(page);83 drawBorder(page);84 drawHeader(page, manifest, pageIndex, pageCount, serif, mono, monoBold);8586 const gridTop = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - 16;87 drawAztecGrid(page, pageImages, startIndex, gridTop, mono);8889 drawFooter(page, manifest, serif, mono);90 }9192 try {93 return await pdfDoc.save();94 } catch (err) {95 throw new CodexError(96 "PDF_GENERATION_FAILED",97 `pdf-lib save() failed: ${err instanceof Error ? err.message : String(err)}`,98 );99 }100}101102103104function drawBackground(page: PDFPage): void {105 page.drawRectangle({ x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT, color: CREAM });106}107108function drawBorder(page: PDFPage): void {109 page.drawRectangle({110 x: MARGIN - 6,111 y: MARGIN - 6,112 width: PAGE_WIDTH - 2 * (MARGIN - 6),113 height: PAGE_HEIGHT - 2 * (MARGIN - 6),114 borderColor: INK,115 borderOpacity: 0.2,116 borderWidth: 0.75,117 });118}119120function drawHeader(121 page: PDFPage,122 manifest: CodexManifest,123 pageIndex: number,124 pageCount: number,125 serif: PDFFont,126 mono: PDFFont,127 monoBold: PDFFont,128): void {129 const y = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT + 14;130131 132 133 134 page.drawText("· peach codex", { x: MARGIN, y, size: 13, font: serif, color: INK });135136 const pageText = `Page ${pageIndex + 1} of ${pageCount}`;137 const pageTextWidth = mono.widthOfTextAtSize(pageText, 9);138 page.drawText(pageText, {139 x: (PAGE_WIDTH - pageTextWidth) / 2,140 y,141 size: 9,142 font: mono,143 color: INK,144 opacity: 0.5,145 });146147 const verifyText = manifest.verifyCode;148 const verifyWidth = monoBold.widthOfTextAtSize(verifyText, 10);149 page.drawText(verifyText, {150 x: PAGE_WIDTH - MARGIN - verifyWidth,151 y,152 size: 10,153 font: monoBold,154 color: INK,155 });156157 page.drawLine({158 start: { x: MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },159 end: { x: PAGE_WIDTH - MARGIN, y: PAGE_HEIGHT - MARGIN - HEADER_HEIGHT },160 thickness: HEADER_HAIRLINE,161 color: INK,162 opacity: 0.2,163 });164}165166167168169170171172173function drawAztecGrid(174 page: PDFPage,175 images: import("pdf-lib").PDFImage[],176 startIndex: number,177 gridTop: number,178 mono: PDFFont,179): void {180 for (let i = 0; i < images.length; i++) {181 const col = i % CODES_PER_ROW;182 const row = Math.floor(i / CODES_PER_ROW);183 const x = MARGIN + col * (CODE_SIZE + COL_GAP);184 const y = gridTop - row * (CODE_SIZE + ROW_GAP + 14) - CODE_SIZE;185186 page.drawImage(images[i]!, { x, y, width: CODE_SIZE, height: CODE_SIZE });187188 const label = String(startIndex + i + 1).padStart(2, "0");189 const labelWidth = mono.widthOfTextAtSize(label, 8);190 page.drawText(label, {191 x: x + CODE_SIZE / 2 - labelWidth / 2,192 y: y - 11,193 size: 8,194 font: mono,195 color: INK,196 opacity: 0.55,197 });198 }199}200201202203function drawFooter(204 page: PDFPage,205 manifest: CodexManifest,206 serif: PDFFont,207 mono: PDFFont,208): void {209 page.drawLine({210 start: { x: MARGIN, y: MARGIN + FOOTER_HEIGHT },211 end: { x: PAGE_WIDTH - MARGIN, y: MARGIN + FOOTER_HEIGHT },212 thickness: FOOTER_HAIRLINE,213 color: INK,214 opacity: 0.15,215 });216217 const footerY = MARGIN + 10;218219 page.drawText("This document restores your Peach vault. Keep it private. Keep it safe.", {220 x: MARGIN,221 y: footerY + 10,222 size: 7,223 font: serif,224 color: INK,225 opacity: 0.45,226 });227228 const date = new Date(manifest.generatedAt).toISOString().slice(0, 10);229 const meta = `${manifest.entryCount} entries · ${date} · ${manifest.verifyCode}`;230 const metaWidth = mono.widthOfTextAtSize(meta, 7);231 page.drawText(meta, {232 x: PAGE_WIDTH - MARGIN - metaWidth,233 y: footerY + 10,234 size: 7,235 font: mono,236 color: INK,237 opacity: 0.45,238 });239240 const siteText = "peachpasswords.com";241 const siteWidth = serif.widthOfTextAtSize(siteText, 7);242 page.drawText(siteText, {243 x: (PAGE_WIDTH - siteWidth) / 2,244 y: footerY,245 size: 7,246 font: serif,247 color: INK,248 opacity: 0.3,249 });250}