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;116117118119const MAX_PDF_PAGES = 250;120121122const MAX_RENDER_SCALE = Math.max(...CANDIDATE_SCALES);123124125126const MAX_RENDERED_DIMENSION = Math.ceil(842 * MAX_RENDER_SCALE * 1.5);127128interface CellDecodeStrategy {129 cropFactor: number;130 normalize: boolean;131 upscale: number;132}133134const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [135 { cropFactor: 1.0, normalize: false, upscale: 1.0 },136 { cropFactor: 0.88, normalize: false, upscale: 1.0 },137 { cropFactor: 1.12, normalize: false, upscale: 1.0 },138 { cropFactor: 1.0, normalize: true, upscale: 1.0 },139 { cropFactor: 0.88, normalize: true, upscale: 1.0 },140 { cropFactor: 1.0, normalize: false, upscale: 2.0 },141 { cropFactor: 1.0, normalize: true, upscale: 2.0 },142];143144const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [145 { cropFactor: 1.0, normalize: false, upscale: 1.0 },146 { cropFactor: 1.12, normalize: false, upscale: 1.0 },147 { cropFactor: 0.88, normalize: false, upscale: 1.0 },148 { cropFactor: 1.0, normalize: true, upscale: 1.0 },149 { cropFactor: 1.12, normalize: true, upscale: 1.0 },150 { cropFactor: 1.0, normalize: false, upscale: 2.0 },151 { cropFactor: 1.0, normalize: true, upscale: 2.0 },152];153154function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {155 return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;156}157158159160161162163164165166167168169async function detectRenderScale(170 pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,171): Promise<number> {172 let bestScale: number = CANDIDATE_SCALES[0];173 let bestRate = -1;174 const probePages = Math.min(3, pdf.numPages);175176 for (const scale of CANDIDATE_SCALES) {177 try {178 let decoded = 0;179 let total = 0;180 for (let pageNum = 1; pageNum <= probePages; pageNum++) {181 const page = await pdf.getPage(pageNum);182 const result = await probePageDecodeRate(page, scale);183 decoded += result.decoded;184 total += result.total;185 }186 const rate = total > 0 ? decoded / total : 0;187 if (rate > bestRate) {188 bestRate = rate;189 bestScale = scale;190 }191 if (rate >= 0.99) {192 return scale;193 }194 } catch {195 196 }197 }198199 return bestScale;200}201202async function probePageDecodeRate(203 page: PdfPage,204 scale: number,205): Promise<{ decoded: number; total: number }> {206 const canvas = await renderPageToCanvas(page, scale);207 const ctx = canvas.getContext("2d");208 if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };209210 let decoded = 0;211 for (let row = 0; row < CODES_PER_COL; row++) {212 for (let col = 0; col < CODES_PER_ROW; col++) {213 const rect = cellRectFor(canvas.width, canvas.height, row, col);214 if (!rect.sw || !rect.sh) continue;215 const text = decodeAztecCellRobust(216 canvas,217 ctx,218 { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },219 scale,220 );221 if (text !== null) decoded++;222 }223 }224 return { decoded, total: CODES_PER_PAGE };225}226227async function renderPageToCanvas(228 page: PdfPage,229 scale: number,230): Promise<HTMLCanvasElement> {231 const viewport = page.getViewport({ scale });232 233 234 235 236 const w = Math.ceil(viewport.width);237 const h = Math.ceil(viewport.height);238 if (w > MAX_RENDERED_DIMENSION || h > MAX_RENDERED_DIMENSION) {239 throw new Error(240 `page dimensions ${w}×${h}px exceed the ${MAX_RENDERED_DIMENSION}px render limit`,241 );242 }243 const canvas = document.createElement("canvas");244 canvas.width = w;245 canvas.height = h;246 const ctx = canvas.getContext("2d");247 if (!ctx) {248 return canvas;249 }250 ctx.fillStyle = "white";251 ctx.fillRect(0, 0, canvas.width, canvas.height);252 await page.render({ canvas, canvasContext: ctx, viewport }).promise;253 return canvas;254}255256257258259260261262263264265266export async function importCodexPDF(267 file: File,268 onProgress?: PDFImportProgress,269): Promise<PDFImportResult> {270 271 272 273 if (file.size > IMPORT_LIMITS.MAX_PDF_BYTES) {274 return {275 chunksFound: 0,276 totalExpected: null,277 envelopes: [],278 errors: [279 `PDF is ${(file.size / (1024 * 1024)).toFixed(1)} MiB — Codex PDFs are limited to ${IMPORT_LIMITS.MAX_PDF_BYTES / (1024 * 1024)} MiB.`,280 ],281 };282 }283284 const pdfjsLib = await getPdfJs();285286 const envelopes: ChunkEnvelope[] = [];287 const errors: string[] = [];288 289 290 const seenIndices = new Set<string>();291 292 293 294 const stripeTotalsForExpected = new Map<number, number>();295296 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;297 try {298 const arrayBuffer = await file.arrayBuffer();299 300 301 302 303 pdf = await pdfjsLib.getDocument({304 data: arrayBuffer,305 306 307 308 isEvalSupported: false,309 disableFontFace: true, 310 disableRange: true, 311 disableStream: true, 312 } as Parameters<typeof pdfjsLib.getDocument>[0]).promise;313 } catch (err) {314 return {315 chunksFound: 0,316 totalExpected: null,317 envelopes,318 errors: [319 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,320 ],321 };322 }323324 325 326 const declaredPages = pdf.numPages;327 const pageCount = Math.min(declaredPages, MAX_PDF_PAGES);328 if (declaredPages > MAX_PDF_PAGES) {329 errors.push(330 `PDF has ${declaredPages} pages; only the first ${MAX_PDF_PAGES} were scanned.`,331 );332 }333 const renderScale = await detectRenderScale(pdf);334335 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {336 onProgress?.(pageNum, pageCount);337338 try {339 const page = await pdf.getPage(pageNum);340 const canvas = await renderPageToCanvas(page, renderScale);341 const ctx = canvas.getContext("2d");342 if (!ctx) {343 errors.push(`Page ${pageNum}: no canvas context`);344 continue;345 }346347 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);348 for (const env of pageEnvelopes) envelopes.push(env);349350 for (const env of pageEnvelopes) {351 const stripe = env.v === 3 ? (env.s ?? 0) : 0;352 353 stripeTotalsForExpected.set(stripe, env.t);354 }355 } catch (err) {356 errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);357 }358 }359360 const metricsEnabled =361 typeof window !== "undefined" &&362 (import.meta.env.MODE === "test" ||363 new URLSearchParams(window.location.search).has("codexMetrics"));364 if (metricsEnabled) {365 const stripeTotals = new Map<number, number>();366 const stripeDecoded = new Map<number, number>();367 for (const env of envelopes) {368 const stripe = env.v === 3 ? (env.s ?? 0) : 0;369 stripeTotals.set(stripe, env.t);370 stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);371 }372 const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);373 const stripeIndices = Array.from(stripeTotals.keys());374 const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;375 (window as Window & {376 __codexImportMetrics?: {377 decodeRate: number;378 shortStripeCount: number;379 perStripeDecoded: number[];380 };381 }).__codexImportMetrics = {382 decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,383 shortStripeCount: Array.from(stripeTotals.entries()).filter(384 ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,385 ).length,386 perStripeDecoded:387 maxStripe >= 0388 ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)389 : [],390 };391 }392393 394 const totalExpected =395 stripeTotalsForExpected.size > 0396 ? Array.from(stripeTotalsForExpected.values()).reduce((sum, t) => sum + t, 0)397 : null;398399 return {400 chunksFound: envelopes.length,401 totalExpected,402 envelopes,403 errors,404 };405}406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435async function scanCanvasGrid(436 canvas: HTMLCanvasElement,437 seenIndices: Set<string>,438 renderScale: number,439): Promise<ChunkEnvelope[]> {440 const found: ChunkEnvelope[] = [];441442 443 444 const cellCanvas = document.createElement("canvas");445 const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });446 if (!cellCtx) return found;447448 for (let row = 0; row < CODES_PER_COL; row++) {449 for (let col = 0; col < CODES_PER_ROW; col++) {450 const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);451 if (sw <= 0 || sh <= 0) continue;452453 cellCanvas.width = sw;454 cellCanvas.height = sh;455 456 457 458 cellCtx.fillStyle = "white";459 cellCtx.fillRect(0, 0, sw, sh);460 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);461462 const text = decodeAztecCellRobust(463 cellCanvas,464 cellCtx,465 { x: 0, y: 0, w: sw, h: sh },466 renderScale,467 );468 if (!text) continue;469470 const envelope = parseChunkEnvelope(text);471 if (!envelope) continue;472 const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;473 if (seenIndices.has(stripeKey)) continue;474 if (!(await verifyChunkIntegrity(envelope))) continue;475476 seenIndices.add(stripeKey);477 found.push(envelope);478 }479 }480481 return found;482}483484function decodeAztecCellRobust(485 canvas: HTMLCanvasElement,486 ctx: CanvasRenderingContext2D,487 cellRect: { x: number; y: number; w: number; h: number },488 renderScale: number,489): string | null {490 const strategies = getCellDecodeStrategies(renderScale);491 for (const strategy of strategies) {492 const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);493 if (result !== null) {494 return result;495 }496 }497 return null;498}499500function decodeAztecCellWithStrategy(501 canvas: HTMLCanvasElement,502 ctx: CanvasRenderingContext2D,503 cellRect: { x: number; y: number; w: number; h: number },504 strategy: CellDecodeStrategy,505): string | null {506 const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);507 if (w <= 0 || h <= 0) return null;508509 const imageData = ctx.getImageData(x, y, w, h);510 const processedData = strategy.normalize511 ? normalizeToBlackWhite(imageData)512 : imageData;513514 const argb = imageDataToArgb(processedData);515 const { buffer, width, height } =516 strategy.upscale > 1517 ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)518 : { buffer: argb, width: processedData.width, height: processedData.height };519520 return decodeAztecFromArgb(buffer, width, height);521}522523function applyCropFactor(524 rect: { x: number; y: number; w: number; h: number },525 factor: number,526 canvasW: number,527 canvasH: number,528): { x: number; y: number; w: number; h: number } {529 const cx = rect.x + rect.w / 2;530 const cy = rect.y + rect.h / 2;531 const nw = Math.max(1, Math.round(rect.w * factor));532 const nh = Math.max(1, Math.round(rect.h * factor));533 const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));534 const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));535 const w = Math.max(1, Math.min(canvasW - x, nw));536 const h = Math.max(1, Math.min(canvasH - y, nh));537 return { x, y, w, h };538}539540function normalizeToBlackWhite(imageData: ImageData): ImageData {541 const data = new Uint8ClampedArray(imageData.data);542 for (let i = 0; i < data.length; i += 4) {543 const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;544 const v = lum > 128 ? 255 : 0;545 data[i] = v;546 data[i + 1] = v;547 data[i + 2] = v;548 data[i + 3] = 255;549 }550 return new ImageData(data, imageData.width, imageData.height);551}552553function upscaleArgb(554 argb: Int32Array,555 width: number,556 height: number,557 scale: number,558): { buffer: Int32Array; width: number; height: number } {559 const targetW = Math.max(1, Math.round(width * scale));560 const targetH = Math.max(1, Math.round(height * scale));561 const out = new Int32Array(targetW * targetH);562 for (let y = 0; y < targetH; y++) {563 const srcY = Math.min(height - 1, Math.floor(y / scale));564 for (let x = 0; x < targetW; x++) {565 const srcX = Math.min(width - 1, Math.floor(x / scale));566 out[y * targetW + x] = argb[srcY * width + srcX]!;567 }568 }569 return { buffer: out, width: targetW, height: targetH };570}571572573574575576577578579580581582export function cellRectFor(583 canvasW: number,584 canvasH: number,585 row: number,586 col: number,587): { sx: number; sy: number; sw: number; sh: number } {588 589 590 const scaleX = canvasW / PAGE_WIDTH;591 const scaleY = canvasH / PAGE_HEIGHT;592593 594 595 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;596 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;597598 const codeWpx = CODE_SIZE * scaleX;599 const codeHpx = CODE_SIZE * scaleY;600 const quietX = Math.round(codeWpx * 0.12);601 const quietY = Math.round(codeHpx * 0.12);602603 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;604 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;605606 const sx = Math.max(0, Math.floor(codeX - quietX));607 const sy = Math.max(0, Math.floor(codeY - quietY));608 const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));609 const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));610 return { sx, sy, sw, sh };611}612613614615616617618export function imageDataToArgb(imageData: ImageData): Int32Array {619 const raw = imageData.data;620 const out = new Int32Array(imageData.width * imageData.height);621 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {622 out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;623 }624 return out;625}626627628629630631632633634635636637638639640641642643644export function decodeAztecFromArgb(645 argb: Int32Array,646 width: number,647 height: number,648): string | null {649 const source = new RGBLuminanceSource(argb, width, height);650651 try {652 const bitmap = new BinaryBitmap(new HybridBinarizer(source));653 return new AztecCodeReader().decode(bitmap).getText();654 } catch (err) {655 if (!(err instanceof NotFoundException)) return null;656 }657658 try {659 const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));660 return new AztecCodeReader().decode(bitmap).getText();661 } catch {662 return null;663 }664}665666667668669670671672673674675676export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {677 let parsed: unknown;678 try {679 parsed = JSON.parse(raw);680 } catch {681 return null;682 }683 if (typeof parsed !== "object" || parsed === null) return null;684 const obj = parsed as Record<string, unknown>;685686 if (obj.v !== 2 && obj.v !== 3) return null;687688 const baseShapeOk =689 typeof obj.c === "string" &&690 Number.isInteger(obj.i) && 691 Number.isInteger(obj.t) &&692 typeof obj.p === "boolean" &&693 typeof obj.d === "string" &&694 typeof obj.h === "string";695 if (!baseShapeOk) return null;696697 698 if (obj.v === 3 && (!Number.isInteger(obj.s) || !Number.isInteger(obj.S))) {699 return null;700 }701702 return obj as unknown as ChunkEnvelope;703}