12345678910111213141516171819import { BrowserAztecCodeReader, NotFoundException } from "@zxing/library";2021import {22 CODES_PER_COL,23 CODES_PER_ROW,24 type ChunkEnvelope,25} from "../../shared/codex-types";26import { verifyChunkIntegrity } from "./helpers";27import {28 CODE_SIZE,29 COL_GAP,30 HEADER_GAP,31 HEADER_HEIGHT,32 MARGIN,33 PAGE_HEIGHT,34 PAGE_WIDTH,35 ROW_STEP,36} from "./pdf-layout";3738394041424344type PdfJsModule = typeof import("pdfjs-dist");45let pdfJsPromise: Promise<PdfJsModule> | null = null;4647async function getPdfJs(): Promise<PdfJsModule> {48 if (!pdfJsPromise) {49 pdfJsPromise = import("pdfjs-dist").then((mod) => {50 if (51 typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||52 mod.GlobalWorkerOptions.workerSrc.length === 053 ) {54 if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {55 mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(56 "pdf.worker.min.mjs",57 );58 } else {59 mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";60 }61 }62 return mod;63 });64 }65 return pdfJsPromise;66}6768export interface PDFImportResult {69 70 chunksFound: number;71 72 totalExpected: number | null;73 74 envelopes: ChunkEnvelope[];75 76 errors: string[];77}7879export interface PDFImportProgress {80 (page: number, totalPages: number): void;81}8283848586878889export async function importCodexPDF(90 file: File,91 onProgress?: PDFImportProgress,92): Promise<PDFImportResult> {93 const pdfjsLib = await getPdfJs();9495 const envelopes: ChunkEnvelope[] = [];96 const errors: string[] = [];97 const seenIndices = new Set<number>();98 let totalExpected: number | null = null;99100 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;101 try {102 const arrayBuffer = await file.arrayBuffer();103 pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;104 } catch (err) {105 return {106 chunksFound: 0,107 totalExpected: null,108 envelopes,109 errors: [110 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,111 ],112 };113 }114115 const reader = new BrowserAztecCodeReader();116 const pageCount = pdf.numPages;117118 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {119 onProgress?.(pageNum, pageCount);120121 try {122 const page = await pdf.getPage(pageNum);123 const viewport = page.getViewport({ scale: 2.0 });124125 const canvas = document.createElement("canvas");126 canvas.width = Math.ceil(viewport.width);127 canvas.height = Math.ceil(viewport.height);128 const ctx = canvas.getContext("2d");129 if (!ctx) {130 errors.push(`Page ${pageNum}: no canvas context`);131 continue;132 }133134 135 136 ctx.fillStyle = "white";137 ctx.fillRect(0, 0, canvas.width, canvas.height);138139 await page.render({ canvas, canvasContext: ctx, viewport }).promise;140141 const pageEnvelopes = await scanCanvasGrid(canvas, reader, seenIndices);142 for (const env of pageEnvelopes) envelopes.push(env);143144 if (totalExpected === null && pageEnvelopes.length > 0) {145 totalExpected = pageEnvelopes[0]!.t;146 }147 } catch (err) {148 errors.push(149 `Page ${pageNum} failed: ${err instanceof Error ? err.message : String(err)}`,150 );151 }152 }153154 return {155 chunksFound: envelopes.length,156 totalExpected,157 envelopes,158 errors,159 };160}161162163164165166167168169170171172173174175176177178179async function scanCanvasGrid(180 canvas: HTMLCanvasElement,181 reader: BrowserAztecCodeReader,182 seenIndices: Set<number>,183): Promise<ChunkEnvelope[]> {184 const found: ChunkEnvelope[] = [];185186 187 188 189 190 const scaleX = canvas.width / PAGE_WIDTH;191 const scaleY = canvas.height / PAGE_HEIGHT;192193 194 195 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;196 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;197198 const codeWpx = CODE_SIZE * scaleX;199 const codeHpx = CODE_SIZE * scaleY;200 const quietX = Math.round(codeWpx * 0.12);201 const quietY = Math.round(codeHpx * 0.12);202203 for (let row = 0; row < CODES_PER_COL; row++) {204 for (let col = 0; col < CODES_PER_ROW; col++) {205 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;206 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;207208 const sx = Math.max(0, Math.floor(codeX - quietX));209 const sy = Math.max(0, Math.floor(codeY - quietY));210 const sw = Math.min(211 canvas.width - sx,212 Math.ceil(codeWpx + 2 * quietX),213 );214 const sh = Math.min(215 canvas.height - sy,216 Math.ceil(codeHpx + 2 * quietY),217 );218 if (sw <= 0 || sh <= 0) continue;219220 const cellCanvas = document.createElement("canvas");221 cellCanvas.width = sw;222 cellCanvas.height = sh;223 const cellCtx = cellCanvas.getContext("2d");224 if (!cellCtx) continue;225226 227 228 229 cellCtx.fillStyle = "white";230 cellCtx.fillRect(0, 0, sw, sh);231 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);232233 let text: string;234 try {235 236 237 238 239 const result = reader.decode(cellCanvas as unknown as HTMLImageElement);240 text = result.getText();241 } catch (err) {242 if (err instanceof NotFoundException) continue;243 continue;244 }245246 const envelope = parseChunkEnvelope(text);247 if (!envelope) continue;248 if (seenIndices.has(envelope.i)) continue;249 if (!(await verifyChunkIntegrity(envelope))) continue;250251 seenIndices.add(envelope.i);252 found.push(envelope);253 }254 }255256 return found;257}258259260261262263264export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {265 let parsed: unknown;266 try {267 parsed = JSON.parse(raw);268 } catch {269 return null;270 }271 if (typeof parsed !== "object" || parsed === null) return null;272 const obj = parsed as Record<string, unknown>;273 if (274 obj.v === 2 &&275 typeof obj.c === "string" &&276 typeof obj.i === "number" &&277 typeof obj.t === "number" &&278 typeof obj.p === "boolean" &&279 typeof obj.d === "string" &&280 typeof obj.h === "string"281 ) {282 return obj as unknown as ChunkEnvelope;283 }284 return null;285}