12345678910111213141516171819import {20 AztecCodeReader,21 BinaryBitmap,22 GlobalHistogramBinarizer,23 HybridBinarizer,24 NotFoundException,25 RGBLuminanceSource,26} from "@zxing/library";2728import {29 CODES_PER_COL,30 CODES_PER_ROW,31 type ChunkEnvelope,32} from "../../shared/codex-types";33import { verifyChunkIntegrity } from "./helpers";34import {35 CODE_SIZE,36 COL_GAP,37 HEADER_GAP,38 HEADER_HEIGHT,39 MARGIN,40 PAGE_HEIGHT,41 PAGE_WIDTH,42 ROW_STEP,43} from "./pdf-layout";4445464748495051type PdfJsModule = typeof import("pdfjs-dist");52let pdfJsPromise: Promise<PdfJsModule> | null = null;5354async function getPdfJs(): Promise<PdfJsModule> {55 if (!pdfJsPromise) {56 pdfJsPromise = import("pdfjs-dist").then((mod) => {57 if (58 typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||59 mod.GlobalWorkerOptions.workerSrc.length === 060 ) {61 if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {62 mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(63 "pdf.worker.min.mjs",64 );65 } else {66 mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";67 }68 }69 return mod;70 });71 }72 return pdfJsPromise;73}7475export interface PDFImportResult {76 77 chunksFound: number;78 79 totalExpected: number | null;80 81 envelopes: ChunkEnvelope[];82 83 errors: string[];84}8586export interface PDFImportProgress {87 (page: number, totalPages: number): void;88}8990919293949596export async function importCodexPDF(97 file: File,98 onProgress?: PDFImportProgress,99): Promise<PDFImportResult> {100 const pdfjsLib = await getPdfJs();101102 const envelopes: ChunkEnvelope[] = [];103 const errors: string[] = [];104 const seenIndices = new Set<number>();105 let totalExpected: number | null = null;106107 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;108 try {109 const arrayBuffer = await file.arrayBuffer();110 pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;111 } catch (err) {112 return {113 chunksFound: 0,114 totalExpected: null,115 envelopes,116 errors: [117 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,118 ],119 };120 }121122 const pageCount = pdf.numPages;123124 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {125 onProgress?.(pageNum, pageCount);126127 try {128 const page = await pdf.getPage(pageNum);129 const viewport = page.getViewport({ scale: 2.0 });130131 const canvas = document.createElement("canvas");132 canvas.width = Math.ceil(viewport.width);133 canvas.height = Math.ceil(viewport.height);134 const ctx = canvas.getContext("2d");135 if (!ctx) {136 errors.push(`Page ${pageNum}: no canvas context`);137 continue;138 }139140 141 142 ctx.fillStyle = "white";143 ctx.fillRect(0, 0, canvas.width, canvas.height);144145 await page.render({ canvas, canvasContext: ctx, viewport }).promise;146147 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices);148 for (const env of pageEnvelopes) envelopes.push(env);149150 if (totalExpected === null && pageEnvelopes.length > 0) {151 totalExpected = pageEnvelopes[0]!.t;152 }153 } catch (err) {154 errors.push(155 `Page ${pageNum} failed: ${err instanceof Error ? err.message : String(err)}`,156 );157 }158 }159160 return {161 chunksFound: envelopes.length,162 totalExpected,163 envelopes,164 errors,165 };166}167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196async function scanCanvasGrid(197 canvas: HTMLCanvasElement,198 seenIndices: Set<number>,199): Promise<ChunkEnvelope[]> {200 const found: ChunkEnvelope[] = [];201202 203 204 205 206 const scaleX = canvas.width / PAGE_WIDTH;207 const scaleY = canvas.height / PAGE_HEIGHT;208209 210 211 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;212 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;213214 const codeWpx = CODE_SIZE * scaleX;215 const codeHpx = CODE_SIZE * scaleY;216 const quietX = Math.round(codeWpx * 0.12);217 const quietY = Math.round(codeHpx * 0.12);218219 220 221 const cellCanvas = document.createElement("canvas");222 const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });223 if (!cellCtx) return found;224225 for (let row = 0; row < CODES_PER_COL; row++) {226 for (let col = 0; col < CODES_PER_ROW; col++) {227 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;228 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;229230 const sx = Math.max(0, Math.floor(codeX - quietX));231 const sy = Math.max(0, Math.floor(codeY - quietY));232 const sw = Math.min(233 canvas.width - sx,234 Math.ceil(codeWpx + 2 * quietX),235 );236 const sh = Math.min(237 canvas.height - sy,238 Math.ceil(codeHpx + 2 * quietY),239 );240 if (sw <= 0 || sh <= 0) continue;241242 cellCanvas.width = sw;243 cellCanvas.height = sh;244 245 246 247 cellCtx.fillStyle = "white";248 cellCtx.fillRect(0, 0, sw, sh);249 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);250251 const text = decodeAztecFromCellPixels(cellCtx, sw, sh);252 if (!text) continue;253254 const envelope = parseChunkEnvelope(text);255 if (!envelope) continue;256 if (seenIndices.has(envelope.i)) continue;257 if (!(await verifyChunkIntegrity(envelope))) continue;258259 seenIndices.add(envelope.i);260 found.push(envelope);261 }262 }263264 return found;265}266267268269270271272273274275276277278function decodeAztecFromCellPixels(279 cellCtx: CanvasRenderingContext2D,280 sw: number,281 sh: number,282): string | null {283 const imageData = cellCtx.getImageData(0, 0, sw, sh);284 const raw = imageData.data;285 const argb = new Int32Array(sw * sh);286 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {287 argb[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;288 }289290 const source = new RGBLuminanceSource(argb, sw, sh);291292 try {293 const bitmap = new BinaryBitmap(new HybridBinarizer(source));294 return new AztecCodeReader().decode(bitmap).getText();295 } catch (err) {296 if (!(err instanceof NotFoundException)) return null;297 }298299 try {300 const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));301 return new AztecCodeReader().decode(bitmap).getText();302 } catch {303 return null;304 }305}306307308309310311312export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {313 let parsed: unknown;314 try {315 parsed = JSON.parse(raw);316 } catch {317 return null;318 }319 if (typeof parsed !== "object" || parsed === null) return null;320 const obj = parsed as Record<string, unknown>;321 if (322 obj.v === 2 &&323 typeof obj.c === "string" &&324 typeof obj.i === "number" &&325 typeof obj.t === "number" &&326 typeof obj.p === "boolean" &&327 typeof obj.d === "string" &&328 typeof obj.h === "string"329 ) {330 return obj as unknown as ChunkEnvelope;331 }332 return null;333}