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 pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;275 } catch (err) {276 return {277 chunksFound: 0,278 totalExpected: null,279 envelopes,280 errors: [281 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,282 ],283 };284 }285286 const pageCount = pdf.numPages;287 const renderScale = await detectRenderScale(pdf);288289 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {290 onProgress?.(pageNum, pageCount);291292 try {293 const page = await pdf.getPage(pageNum);294 const canvas = await renderPageToCanvas(page, renderScale);295 const ctx = canvas.getContext("2d");296 if (!ctx) {297 errors.push(`Page ${pageNum}: no canvas context`);298 continue;299 }300301 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);302 for (const env of pageEnvelopes) envelopes.push(env);303304 if (totalExpected === null && pageEnvelopes.length > 0) {305 totalExpected = pageEnvelopes[0]!.t;306 }307 } catch (err) {308 errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);309 }310 }311312 const metricsEnabled =313 typeof window !== "undefined" &&314 (import.meta.env.MODE === "test" ||315 new URLSearchParams(window.location.search).has("codexMetrics"));316 if (metricsEnabled) {317 const stripeTotals = new Map<number, number>();318 const stripeDecoded = new Map<number, number>();319 for (const env of envelopes) {320 const stripe = env.v === 3 ? (env.s ?? 0) : 0;321 stripeTotals.set(stripe, env.t);322 stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);323 }324 const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);325 const stripeIndices = Array.from(stripeTotals.keys());326 const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;327 (window as Window & {328 __codexImportMetrics?: {329 decodeRate: number;330 shortStripeCount: number;331 perStripeDecoded: number[];332 };333 }).__codexImportMetrics = {334 decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,335 shortStripeCount: Array.from(stripeTotals.entries()).filter(336 ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,337 ).length,338 perStripeDecoded:339 maxStripe >= 0340 ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)341 : [],342 };343 }344345 return {346 chunksFound: envelopes.length,347 totalExpected,348 envelopes,349 errors,350 };351}352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381async function scanCanvasGrid(382 canvas: HTMLCanvasElement,383 seenIndices: Set<string>,384 renderScale: number,385): Promise<ChunkEnvelope[]> {386 const found: ChunkEnvelope[] = [];387388 389 390 const cellCanvas = document.createElement("canvas");391 const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });392 if (!cellCtx) return found;393394 for (let row = 0; row < CODES_PER_COL; row++) {395 for (let col = 0; col < CODES_PER_ROW; col++) {396 const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);397 if (sw <= 0 || sh <= 0) continue;398399 cellCanvas.width = sw;400 cellCanvas.height = sh;401 402 403 404 cellCtx.fillStyle = "white";405 cellCtx.fillRect(0, 0, sw, sh);406 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);407408 const text = decodeAztecCellRobust(409 cellCanvas,410 cellCtx,411 { x: 0, y: 0, w: sw, h: sh },412 renderScale,413 );414 if (!text) continue;415416 const envelope = parseChunkEnvelope(text);417 if (!envelope) continue;418 const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;419 if (seenIndices.has(stripeKey)) continue;420 if (!(await verifyChunkIntegrity(envelope))) continue;421422 seenIndices.add(stripeKey);423 found.push(envelope);424 }425 }426427 return found;428}429430function decodeAztecCellRobust(431 canvas: HTMLCanvasElement,432 ctx: CanvasRenderingContext2D,433 cellRect: { x: number; y: number; w: number; h: number },434 renderScale: number,435): string | null {436 const strategies = getCellDecodeStrategies(renderScale);437 for (const strategy of strategies) {438 const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);439 if (result !== null) {440 return result;441 }442 }443 return null;444}445446function decodeAztecCellWithStrategy(447 canvas: HTMLCanvasElement,448 ctx: CanvasRenderingContext2D,449 cellRect: { x: number; y: number; w: number; h: number },450 strategy: CellDecodeStrategy,451): string | null {452 const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);453 if (w <= 0 || h <= 0) return null;454455 const imageData = ctx.getImageData(x, y, w, h);456 const processedData = strategy.normalize457 ? normalizeToBlackWhite(imageData)458 : imageData;459460 const argb = imageDataToArgb(processedData);461 const { buffer, width, height } =462 strategy.upscale > 1463 ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)464 : { buffer: argb, width: processedData.width, height: processedData.height };465466 return decodeAztecFromArgb(buffer, width, height);467}468469function applyCropFactor(470 rect: { x: number; y: number; w: number; h: number },471 factor: number,472 canvasW: number,473 canvasH: number,474): { x: number; y: number; w: number; h: number } {475 const cx = rect.x + rect.w / 2;476 const cy = rect.y + rect.h / 2;477 const nw = Math.max(1, Math.round(rect.w * factor));478 const nh = Math.max(1, Math.round(rect.h * factor));479 const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));480 const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));481 const w = Math.max(1, Math.min(canvasW - x, nw));482 const h = Math.max(1, Math.min(canvasH - y, nh));483 return { x, y, w, h };484}485486function normalizeToBlackWhite(imageData: ImageData): ImageData {487 const data = new Uint8ClampedArray(imageData.data);488 for (let i = 0; i < data.length; i += 4) {489 const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;490 const v = lum > 128 ? 255 : 0;491 data[i] = v;492 data[i + 1] = v;493 data[i + 2] = v;494 data[i + 3] = 255;495 }496 return new ImageData(data, imageData.width, imageData.height);497}498499function upscaleArgb(500 argb: Int32Array,501 width: number,502 height: number,503 scale: number,504): { buffer: Int32Array; width: number; height: number } {505 const targetW = Math.max(1, Math.round(width * scale));506 const targetH = Math.max(1, Math.round(height * scale));507 const out = new Int32Array(targetW * targetH);508 for (let y = 0; y < targetH; y++) {509 const srcY = Math.min(height - 1, Math.floor(y / scale));510 for (let x = 0; x < targetW; x++) {511 const srcX = Math.min(width - 1, Math.floor(x / scale));512 out[y * targetW + x] = argb[srcY * width + srcX]!;513 }514 }515 return { buffer: out, width: targetW, height: targetH };516}517518519520521522523524525526527528export function cellRectFor(529 canvasW: number,530 canvasH: number,531 row: number,532 col: number,533): { sx: number; sy: number; sw: number; sh: number } {534 535 536 const scaleX = canvasW / PAGE_WIDTH;537 const scaleY = canvasH / PAGE_HEIGHT;538539 540 541 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;542 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;543544 const codeWpx = CODE_SIZE * scaleX;545 const codeHpx = CODE_SIZE * scaleY;546 const quietX = Math.round(codeWpx * 0.12);547 const quietY = Math.round(codeHpx * 0.12);548549 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;550 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;551552 const sx = Math.max(0, Math.floor(codeX - quietX));553 const sy = Math.max(0, Math.floor(codeY - quietY));554 const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));555 const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));556 return { sx, sy, sw, sh };557}558559560561562563564export function imageDataToArgb(imageData: ImageData): Int32Array {565 const raw = imageData.data;566 const out = new Int32Array(imageData.width * imageData.height);567 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {568 out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;569 }570 return out;571}572573574575576577578579580581582583584585586587588589590export function decodeAztecFromArgb(591 argb: Int32Array,592 width: number,593 height: number,594): string | null {595 const source = new RGBLuminanceSource(argb, width, height);596597 try {598 const bitmap = new BinaryBitmap(new HybridBinarizer(source));599 return new AztecCodeReader().decode(bitmap).getText();600 } catch (err) {601 if (!(err instanceof NotFoundException)) return null;602 }603604 try {605 const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));606 return new AztecCodeReader().decode(bitmap).getText();607 } catch {608 return null;609 }610}611612613614615616617618619620621622export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {623 let parsed: unknown;624 try {625 parsed = JSON.parse(raw);626 } catch {627 return null;628 }629 if (typeof parsed !== "object" || parsed === null) return null;630 const obj = parsed as Record<string, unknown>;631632 if (obj.v !== 2 && obj.v !== 3) return null;633634 const baseShapeOk =635 typeof obj.c === "string" &&636 typeof obj.i === "number" &&637 typeof obj.t === "number" &&638 typeof obj.p === "boolean" &&639 typeof obj.d === "string" &&640 typeof obj.h === "string";641 if (!baseShapeOk) return null;642643 644 if (obj.v === 3 && (typeof obj.s !== "number" || typeof obj.S !== "number")) {645 return null;646 }647648 return obj as unknown as ChunkEnvelope;649}