1234567891011121314151617181920212223242526import {27 AztecCodeReader,28 BinaryBitmap,29 GlobalHistogramBinarizer,30 HybridBinarizer,31 NotFoundException,32 RGBLuminanceSource,33} from "@zxing/library";3435import {36 CODES_PER_COL,37 CODES_PER_ROW,38 type ChunkEnvelope,39} from "../../shared/codex-types";40import { verifyChunkIntegrity } from "./helpers";41import {42 CODE_SIZE,43 COL_GAP,44 HEADER_GAP,45 HEADER_HEIGHT,46 MARGIN,47 PAGE_HEIGHT,48 PAGE_WIDTH,49 ROW_STEP,50} from "./pdf-layout";5152535455565758type PdfJsModule = typeof import("pdfjs-dist");59let pdfJsPromise: Promise<PdfJsModule> | null = null;6061async function getPdfJs(): Promise<PdfJsModule> {62 if (!pdfJsPromise) {63 pdfJsPromise = import("pdfjs-dist").then((mod) => {64 if (65 typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||66 mod.GlobalWorkerOptions.workerSrc.length === 067 ) {68 if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {69 mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(70 "pdf.worker.min.mjs",71 );72 } else {73 mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";74 }75 }76 return mod;77 });78 }79 return pdfJsPromise;80}8182export interface PDFImportResult {83 84 chunksFound: number;85 86 totalExpected: number | null;87 88 envelopes: ChunkEnvelope[];89 90 errors: string[];91}9293export interface PDFImportProgress {94 (page: number, totalPages: number): void;95}96979899100101102103export async function importCodexPDF(104 file: File,105 onProgress?: PDFImportProgress,106): Promise<PDFImportResult> {107 const pdfjsLib = await getPdfJs();108109 const envelopes: ChunkEnvelope[] = [];110 const errors: string[] = [];111 const seenIndices = new Set<number>();112 let totalExpected: number | null = null;113114 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;115 try {116 const arrayBuffer = await file.arrayBuffer();117 pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;118 } catch (err) {119 return {120 chunksFound: 0,121 totalExpected: null,122 envelopes,123 errors: [124 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,125 ],126 };127 }128129 const pageCount = pdf.numPages;130131 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {132 onProgress?.(pageNum, pageCount);133134 try {135 const page = await pdf.getPage(pageNum);136 const viewport = page.getViewport({ scale: 2.0 });137138 const canvas = document.createElement("canvas");139 canvas.width = Math.ceil(viewport.width);140 canvas.height = Math.ceil(viewport.height);141 const ctx = canvas.getContext("2d");142 if (!ctx) {143 errors.push(`Page ${pageNum}: no canvas context`);144 continue;145 }146147 148 149 ctx.fillStyle = "white";150 ctx.fillRect(0, 0, canvas.width, canvas.height);151152 await page.render({ canvas, canvasContext: ctx, viewport }).promise;153154 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices);155 for (const env of pageEnvelopes) envelopes.push(env);156157 if (totalExpected === null && pageEnvelopes.length > 0) {158 totalExpected = pageEnvelopes[0]!.t;159 }160 } catch (err) {161 errors.push(162 `Page ${pageNum} failed: ${err instanceof Error ? err.message : String(err)}`,163 );164 }165 }166167 return {168 chunksFound: envelopes.length,169 totalExpected,170 envelopes,171 errors,172 };173}174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203async function scanCanvasGrid(204 canvas: HTMLCanvasElement,205 seenIndices: Set<number>,206): Promise<ChunkEnvelope[]> {207 const found: ChunkEnvelope[] = [];208209 210 211 const cellCanvas = document.createElement("canvas");212 const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });213 if (!cellCtx) return found;214215 for (let row = 0; row < CODES_PER_COL; row++) {216 for (let col = 0; col < CODES_PER_ROW; col++) {217 const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);218 if (sw <= 0 || sh <= 0) continue;219220 cellCanvas.width = sw;221 cellCanvas.height = sh;222 223 224 225 cellCtx.fillStyle = "white";226 cellCtx.fillRect(0, 0, sw, sh);227 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);228229 const imageData = cellCtx.getImageData(0, 0, sw, sh);230 const argb = imageDataToArgb(imageData);231 const text = decodeAztecFromArgb(argb, sw, sh);232 if (!text) continue;233234 const envelope = parseChunkEnvelope(text);235 if (!envelope) continue;236 if (seenIndices.has(envelope.i)) continue;237 if (!(await verifyChunkIntegrity(envelope))) continue;238239 seenIndices.add(envelope.i);240 found.push(envelope);241 }242 }243244 return found;245}246247248249250251252253254255256257export function cellRectFor(258 canvasW: number,259 canvasH: number,260 row: number,261 col: number,262): { sx: number; sy: number; sw: number; sh: number } {263 264 265 const scaleX = canvasW / PAGE_WIDTH;266 const scaleY = canvasH / PAGE_HEIGHT;267268 269 270 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;271 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;272273 const codeWpx = CODE_SIZE * scaleX;274 const codeHpx = CODE_SIZE * scaleY;275 const quietX = Math.round(codeWpx * 0.12);276 const quietY = Math.round(codeHpx * 0.12);277278 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;279 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;280281 const sx = Math.max(0, Math.floor(codeX - quietX));282 const sy = Math.max(0, Math.floor(codeY - quietY));283 const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));284 const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));285 return { sx, sy, sw, sh };286}287288289290291292293export function imageDataToArgb(imageData: ImageData): Int32Array {294 const raw = imageData.data;295 const out = new Int32Array(imageData.width * imageData.height);296 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {297 out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;298 }299 return out;300}301302303304305306307308309310311312313314315316317318319export function decodeAztecFromArgb(320 argb: Int32Array,321 width: number,322 height: number,323): string | null {324 const source = new RGBLuminanceSource(argb, width, height);325326 try {327 const bitmap = new BinaryBitmap(new HybridBinarizer(source));328 return new AztecCodeReader().decode(bitmap).getText();329 } catch (err) {330 if (!(err instanceof NotFoundException)) return null;331 }332333 try {334 const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));335 return new AztecCodeReader().decode(bitmap).getText();336 } catch {337 return null;338 }339}340341342343344345346347348349350351export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {352 let parsed: unknown;353 try {354 parsed = JSON.parse(raw);355 } catch {356 return null;357 }358 if (typeof parsed !== "object" || parsed === null) return null;359 const obj = parsed as Record<string, unknown>;360361 if (obj.v !== 2 && obj.v !== 3) return null;362363 const baseShapeOk =364 typeof obj.c === "string" &&365 typeof obj.i === "number" &&366 typeof obj.t === "number" &&367 typeof obj.p === "boolean" &&368 typeof obj.d === "string" &&369 typeof obj.h === "string";370 if (!baseShapeOk) return null;371372 373 if (obj.v === 3 && (typeof obj.s !== "number" || typeof obj.S !== "number")) {374 return null;375 }376377 return obj as unknown as ChunkEnvelope;378}