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_PAGE,38 CODES_PER_ROW,39 type ChunkEnvelope,40} from "../../shared/codex-types";41import { verifyChunkIntegrity } from "./helpers";42import { IMPORT_LIMITS } from "../../shared/import-limits";43import {44 CODE_SIZE,45 COL_GAP,46 HEADER_GAP,47 HEADER_HEIGHT,48 MARGIN,49 PAGE_HEIGHT,50 PAGE_WIDTH,51 ROW_STEP,52} from "./pdf-layout";5354555657585960type PdfJsModule = typeof import("pdfjs-dist");61type PdfPage = any;62let pdfJsPromise: Promise<PdfJsModule> | null = null;6364async function getPdfJs(): Promise<PdfJsModule> {65 if (!pdfJsPromise) {66 pdfJsPromise = import("pdfjs-dist").then((mod) => {67 if (68 typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||69 mod.GlobalWorkerOptions.workerSrc.length === 070 ) {71 if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {72 mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(73 "pdf.worker.min.mjs",74 );75 } else {76 mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";77 }78 }79 return mod;80 });81 }82 return pdfJsPromise;83}8485export interface PDFImportResult {86 87 chunksFound: number;88 89 totalExpected: number | null;90 91 envelopes: ChunkEnvelope[];92 93 errors: string[];94}9596export interface PDFImportProgress {97 (page: number, totalPages: number): void;98}99100101102103104105106107108109110111112113114115const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;116117interface CellDecodeStrategy {118 cropFactor: number;119 normalize: boolean;120 upscale: number;121}122123const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [124 { cropFactor: 1.0, normalize: false, upscale: 1.0 },125 { cropFactor: 0.88, normalize: false, upscale: 1.0 },126 { cropFactor: 1.12, normalize: false, upscale: 1.0 },127 { cropFactor: 1.0, normalize: true, upscale: 1.0 },128 { cropFactor: 0.88, normalize: true, upscale: 1.0 },129 { cropFactor: 1.0, normalize: false, upscale: 2.0 },130 { cropFactor: 1.0, normalize: true, upscale: 2.0 },131];132133const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [134 { cropFactor: 1.0, normalize: false, upscale: 1.0 },135 { cropFactor: 1.12, normalize: false, upscale: 1.0 },136 { cropFactor: 0.88, normalize: false, upscale: 1.0 },137 { cropFactor: 1.0, normalize: true, upscale: 1.0 },138 { cropFactor: 1.12, normalize: true, upscale: 1.0 },139 { cropFactor: 1.0, normalize: false, upscale: 2.0 },140 { cropFactor: 1.0, normalize: true, upscale: 2.0 },141];142143function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {144 return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;145}146147148149150151152153154155156157158async function detectRenderScale(159 pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,160): Promise<number> {161 let bestScale: number = CANDIDATE_SCALES[0];162 let bestRate = -1;163 const probePages = Math.min(3, pdf.numPages);164165 for (const scale of CANDIDATE_SCALES) {166 try {167 let decoded = 0;168 let total = 0;169 for (let pageNum = 1; pageNum <= probePages; pageNum++) {170 const page = await pdf.getPage(pageNum);171 const result = await probePageDecodeRate(page, scale);172 decoded += result.decoded;173 total += result.total;174 }175 const rate = total > 0 ? decoded / total : 0;176 if (rate > bestRate) {177 bestRate = rate;178 bestScale = scale;179 }180 if (rate >= 0.99) {181 return scale;182 }183 } catch {184 185 }186 }187188 return bestScale;189}190191async function probePageDecodeRate(192 page: PdfPage,193 scale: number,194): Promise<{ decoded: number; total: number }> {195 const canvas = await renderPageToCanvas(page, scale);196 const ctx = canvas.getContext("2d");197 if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };198199 let decoded = 0;200 for (let row = 0; row < CODES_PER_COL; row++) {201 for (let col = 0; col < CODES_PER_ROW; col++) {202 const rect = cellRectFor(canvas.width, canvas.height, row, col);203 if (!rect.sw || !rect.sh) continue;204 const text = decodeAztecCellRobust(205 canvas,206 ctx,207 { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },208 scale,209 );210 if (text !== null) decoded++;211 }212 }213 return { decoded, total: CODES_PER_PAGE };214}215216async function renderPageToCanvas(217 page: PdfPage,218 scale: number,219): Promise<HTMLCanvasElement> {220 const viewport = page.getViewport({ scale });221 const canvas = document.createElement("canvas");222 canvas.width = Math.ceil(viewport.width);223 canvas.height = Math.ceil(viewport.height);224 const ctx = canvas.getContext("2d");225 if (!ctx) {226 return canvas;227 }228 ctx.fillStyle = "white";229 ctx.fillRect(0, 0, canvas.width, canvas.height);230 await page.render({ canvas, canvasContext: ctx, viewport }).promise;231 return canvas;232}233234235236237238239240241242243244export async function importCodexPDF(245 file: File,246 onProgress?: PDFImportProgress,247): Promise<PDFImportResult> {248 249 250 251 if (file.size > IMPORT_LIMITS.MAX_PDF_BYTES) {252 return {253 chunksFound: 0,254 totalExpected: null,255 envelopes: [],256 errors: [257 `PDF is ${(file.size / (1024 * 1024)).toFixed(1)} MiB — Codex PDFs are limited to ${IMPORT_LIMITS.MAX_PDF_BYTES / (1024 * 1024)} MiB.`,258 ],259 };260 }261262 const pdfjsLib = await getPdfJs();263264 const envelopes: ChunkEnvelope[] = [];265 const errors: string[] = [];266 267 268 const seenIndices = new Set<string>();269 let totalExpected: number | null = null;270271 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;272 try {273 const arrayBuffer = await file.arrayBuffer();274 275 276 277 278 pdf = await pdfjsLib.getDocument({279 data: arrayBuffer,280 281 282 283 isEvalSupported: false,284 disableFontFace: true, 285 disableRange: true, 286 disableStream: true, 287 } as Parameters<typeof pdfjsLib.getDocument>[0]).promise;288 } catch (err) {289 return {290 chunksFound: 0,291 totalExpected: null,292 envelopes,293 errors: [294 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,295 ],296 };297 }298299 const pageCount = pdf.numPages;300 const renderScale = await detectRenderScale(pdf);301302 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {303 onProgress?.(pageNum, pageCount);304305 try {306 const page = await pdf.getPage(pageNum);307 const canvas = await renderPageToCanvas(page, renderScale);308 const ctx = canvas.getContext("2d");309 if (!ctx) {310 errors.push(`Page ${pageNum}: no canvas context`);311 continue;312 }313314 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);315 for (const env of pageEnvelopes) envelopes.push(env);316317 if (totalExpected === null && pageEnvelopes.length > 0) {318 totalExpected = pageEnvelopes[0]!.t;319 }320 } catch (err) {321 errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);322 }323 }324325 const metricsEnabled =326 typeof window !== "undefined" &&327 (import.meta.env.MODE === "test" ||328 new URLSearchParams(window.location.search).has("codexMetrics"));329 if (metricsEnabled) {330 const stripeTotals = new Map<number, number>();331 const stripeDecoded = new Map<number, number>();332 for (const env of envelopes) {333 const stripe = env.v === 3 ? (env.s ?? 0) : 0;334 stripeTotals.set(stripe, env.t);335 stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);336 }337 const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);338 const stripeIndices = Array.from(stripeTotals.keys());339 const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;340 (window as Window & {341 __codexImportMetrics?: {342 decodeRate: number;343 shortStripeCount: number;344 perStripeDecoded: number[];345 };346 }).__codexImportMetrics = {347 decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,348 shortStripeCount: Array.from(stripeTotals.entries()).filter(349 ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,350 ).length,351 perStripeDecoded:352 maxStripe >= 0353 ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)354 : [],355 };356 }357358 return {359 chunksFound: envelopes.length,360 totalExpected,361 envelopes,362 errors,363 };364}365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394async function scanCanvasGrid(395 canvas: HTMLCanvasElement,396 seenIndices: Set<string>,397 renderScale: number,398): Promise<ChunkEnvelope[]> {399 const found: ChunkEnvelope[] = [];400401 402 403 const cellCanvas = document.createElement("canvas");404 const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });405 if (!cellCtx) return found;406407 for (let row = 0; row < CODES_PER_COL; row++) {408 for (let col = 0; col < CODES_PER_ROW; col++) {409 const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);410 if (sw <= 0 || sh <= 0) continue;411412 cellCanvas.width = sw;413 cellCanvas.height = sh;414 415 416 417 cellCtx.fillStyle = "white";418 cellCtx.fillRect(0, 0, sw, sh);419 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);420421 const text = decodeAztecCellRobust(422 cellCanvas,423 cellCtx,424 { x: 0, y: 0, w: sw, h: sh },425 renderScale,426 );427 if (!text) continue;428429 const envelope = parseChunkEnvelope(text);430 if (!envelope) continue;431 const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;432 if (seenIndices.has(stripeKey)) continue;433 if (!(await verifyChunkIntegrity(envelope))) continue;434435 seenIndices.add(stripeKey);436 found.push(envelope);437 }438 }439440 return found;441}442443function decodeAztecCellRobust(444 canvas: HTMLCanvasElement,445 ctx: CanvasRenderingContext2D,446 cellRect: { x: number; y: number; w: number; h: number },447 renderScale: number,448): string | null {449 const strategies = getCellDecodeStrategies(renderScale);450 for (const strategy of strategies) {451 const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);452 if (result !== null) {453 return result;454 }455 }456 return null;457}458459function decodeAztecCellWithStrategy(460 canvas: HTMLCanvasElement,461 ctx: CanvasRenderingContext2D,462 cellRect: { x: number; y: number; w: number; h: number },463 strategy: CellDecodeStrategy,464): string | null {465 const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);466 if (w <= 0 || h <= 0) return null;467468 const imageData = ctx.getImageData(x, y, w, h);469 const processedData = strategy.normalize470 ? normalizeToBlackWhite(imageData)471 : imageData;472473 const argb = imageDataToArgb(processedData);474 const { buffer, width, height } =475 strategy.upscale > 1476 ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)477 : { buffer: argb, width: processedData.width, height: processedData.height };478479 return decodeAztecFromArgb(buffer, width, height);480}481482function applyCropFactor(483 rect: { x: number; y: number; w: number; h: number },484 factor: number,485 canvasW: number,486 canvasH: number,487): { x: number; y: number; w: number; h: number } {488 const cx = rect.x + rect.w / 2;489 const cy = rect.y + rect.h / 2;490 const nw = Math.max(1, Math.round(rect.w * factor));491 const nh = Math.max(1, Math.round(rect.h * factor));492 const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));493 const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));494 const w = Math.max(1, Math.min(canvasW - x, nw));495 const h = Math.max(1, Math.min(canvasH - y, nh));496 return { x, y, w, h };497}498499function normalizeToBlackWhite(imageData: ImageData): ImageData {500 const data = new Uint8ClampedArray(imageData.data);501 for (let i = 0; i < data.length; i += 4) {502 const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;503 const v = lum > 128 ? 255 : 0;504 data[i] = v;505 data[i + 1] = v;506 data[i + 2] = v;507 data[i + 3] = 255;508 }509 return new ImageData(data, imageData.width, imageData.height);510}511512function upscaleArgb(513 argb: Int32Array,514 width: number,515 height: number,516 scale: number,517): { buffer: Int32Array; width: number; height: number } {518 const targetW = Math.max(1, Math.round(width * scale));519 const targetH = Math.max(1, Math.round(height * scale));520 const out = new Int32Array(targetW * targetH);521 for (let y = 0; y < targetH; y++) {522 const srcY = Math.min(height - 1, Math.floor(y / scale));523 for (let x = 0; x < targetW; x++) {524 const srcX = Math.min(width - 1, Math.floor(x / scale));525 out[y * targetW + x] = argb[srcY * width + srcX]!;526 }527 }528 return { buffer: out, width: targetW, height: targetH };529}530531532533534535536537538539540541export function cellRectFor(542 canvasW: number,543 canvasH: number,544 row: number,545 col: number,546): { sx: number; sy: number; sw: number; sh: number } {547 548 549 const scaleX = canvasW / PAGE_WIDTH;550 const scaleY = canvasH / PAGE_HEIGHT;551552 553 554 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;555 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;556557 const codeWpx = CODE_SIZE * scaleX;558 const codeHpx = CODE_SIZE * scaleY;559 const quietX = Math.round(codeWpx * 0.12);560 const quietY = Math.round(codeHpx * 0.12);561562 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;563 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;564565 const sx = Math.max(0, Math.floor(codeX - quietX));566 const sy = Math.max(0, Math.floor(codeY - quietY));567 const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));568 const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));569 return { sx, sy, sw, sh };570}571572573574575576577export function imageDataToArgb(imageData: ImageData): Int32Array {578 const raw = imageData.data;579 const out = new Int32Array(imageData.width * imageData.height);580 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {581 out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;582 }583 return out;584}585586587588589590591592593594595596597598599600601602603export function decodeAztecFromArgb(604 argb: Int32Array,605 width: number,606 height: number,607): string | null {608 const source = new RGBLuminanceSource(argb, width, height);609610 try {611 const bitmap = new BinaryBitmap(new HybridBinarizer(source));612 return new AztecCodeReader().decode(bitmap).getText();613 } catch (err) {614 if (!(err instanceof NotFoundException)) return null;615 }616617 try {618 const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));619 return new AztecCodeReader().decode(bitmap).getText();620 } catch {621 return null;622 }623}624625626627628629630631632633634635export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {636 let parsed: unknown;637 try {638 parsed = JSON.parse(raw);639 } catch {640 return null;641 }642 if (typeof parsed !== "object" || parsed === null) return null;643 const obj = parsed as Record<string, unknown>;644645 if (obj.v !== 2 && obj.v !== 3) return null;646647 const baseShapeOk =648 typeof obj.c === "string" &&649 Number.isInteger(obj.i) && 650 Number.isInteger(obj.t) &&651 typeof obj.p === "boolean" &&652 typeof obj.d === "string" &&653 typeof obj.h === "string";654 if (!baseShapeOk) return null;655656 657 if (obj.v === 3 && (!Number.isInteger(obj.s) || !Number.isInteger(obj.S))) {658 return null;659 }660661 return obj as unknown as ChunkEnvelope;662}