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 type PDFImportProgress = (page: number, totalPages: number) => void979899100101102103104105106107108109110111112113const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;114115116117const MAX_PDF_PAGES = 250;118119120const MAX_RENDER_SCALE = Math.max(...CANDIDATE_SCALES);121122123124const MAX_RENDERED_DIMENSION = Math.ceil(842 * MAX_RENDER_SCALE * 1.5);125126interface CellDecodeStrategy {127 cropFactor: number;128 normalize: boolean;129 upscale: number;130}131132const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [133 { cropFactor: 1.0, normalize: false, upscale: 1.0 },134 { cropFactor: 0.88, normalize: false, upscale: 1.0 },135 { cropFactor: 1.12, normalize: false, upscale: 1.0 },136 { cropFactor: 1.0, normalize: true, upscale: 1.0 },137 { cropFactor: 0.88, normalize: true, upscale: 1.0 },138 { cropFactor: 1.0, normalize: false, upscale: 2.0 },139 { cropFactor: 1.0, normalize: true, upscale: 2.0 },140];141142const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [143 { cropFactor: 1.0, normalize: false, upscale: 1.0 },144 { cropFactor: 1.12, normalize: false, upscale: 1.0 },145 { cropFactor: 0.88, normalize: false, upscale: 1.0 },146 { cropFactor: 1.0, normalize: true, upscale: 1.0 },147 { cropFactor: 1.12, normalize: true, upscale: 1.0 },148 { cropFactor: 1.0, normalize: false, upscale: 2.0 },149 { cropFactor: 1.0, normalize: true, upscale: 2.0 },150];151152function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {153 return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;154}155156157158159160161162163164165166167async function detectRenderScale(168 pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,169): Promise<number> {170 let bestScale: number = CANDIDATE_SCALES[0];171 let bestRate = -1;172 const probePages = Math.min(3, pdf.numPages);173174 for (const scale of CANDIDATE_SCALES) {175 try {176 let decoded = 0;177 let total = 0;178 for (let pageNum = 1; pageNum <= probePages; pageNum++) {179 const page = await pdf.getPage(pageNum);180 const result = await probePageDecodeRate(page, scale);181 decoded += result.decoded;182 total += result.total;183 }184 const rate = total > 0 ? decoded / total : 0;185 if (rate > bestRate) {186 bestRate = rate;187 bestScale = scale;188 }189 if (rate >= 0.99) {190 return scale;191 }192 } catch {193 194 }195 }196197 return bestScale;198}199200async function probePageDecodeRate(201 page: PdfPage,202 scale: number,203): Promise<{ decoded: number; total: number }> {204 const canvas = await renderPageToCanvas(page, scale);205 const ctx = canvas.getContext("2d");206 if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };207208 let decoded = 0;209 for (let row = 0; row < CODES_PER_COL; row++) {210 for (let col = 0; col < CODES_PER_ROW; col++) {211 const rect = cellRectFor(canvas.width, canvas.height, row, col);212 if (!rect.sw || !rect.sh) continue;213 const text = decodeAztecCellRobust(214 canvas,215 ctx,216 { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },217 scale,218 );219 if (text !== null) decoded++;220 }221 }222 return { decoded, total: CODES_PER_PAGE };223}224225async function renderPageToCanvas(226 page: PdfPage,227 scale: number,228): Promise<HTMLCanvasElement> {229 const viewport = page.getViewport({ scale });230 231 232 233 234 const w = Math.ceil(viewport.width);235 const h = Math.ceil(viewport.height);236 if (w > MAX_RENDERED_DIMENSION || h > MAX_RENDERED_DIMENSION) {237 throw new Error(238 `page dimensions ${w}×${h}px exceed the ${MAX_RENDERED_DIMENSION}px render limit`,239 );240 }241 const canvas = document.createElement("canvas");242 canvas.width = w;243 canvas.height = h;244 const ctx = canvas.getContext("2d");245 if (!ctx) {246 return canvas;247 }248 ctx.fillStyle = "white";249 ctx.fillRect(0, 0, canvas.width, canvas.height);250 await page.render({ canvas, canvasContext: ctx, viewport }).promise;251 return canvas;252}253254255256257258259260261262263264export async function importCodexPDF(265 file: File,266 onProgress?: PDFImportProgress,267): Promise<PDFImportResult> {268 269 270 271 if (file.size > IMPORT_LIMITS.MAX_PDF_BYTES) {272 return {273 chunksFound: 0,274 totalExpected: null,275 envelopes: [],276 errors: [277 `PDF is ${(file.size / (1024 * 1024)).toFixed(1)} MiB — Codex PDFs are limited to ${IMPORT_LIMITS.MAX_PDF_BYTES / (1024 * 1024)} MiB.`,278 ],279 };280 }281282 const pdfjsLib = await getPdfJs();283284 const envelopes: ChunkEnvelope[] = [];285 const errors: string[] = [];286 287 288 const seenIndices = new Set<string>();289 290 291 292 const stripeTotalsForExpected = new Map<number, number>();293294 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;295 try {296 const arrayBuffer = await file.arrayBuffer();297 298 299 300 301 pdf = await pdfjsLib.getDocument({302 data: arrayBuffer,303 304 305 306 isEvalSupported: false,307 disableFontFace: true, 308 disableRange: true, 309 disableStream: true, 310 } as Parameters<typeof pdfjsLib.getDocument>[0]).promise;311 } catch (err) {312 return {313 chunksFound: 0,314 totalExpected: null,315 envelopes,316 errors: [317 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,318 ],319 };320 }321322 323 324 const declaredPages = pdf.numPages;325 const pageCount = Math.min(declaredPages, MAX_PDF_PAGES);326 if (declaredPages > MAX_PDF_PAGES) {327 errors.push(328 `PDF has ${declaredPages} pages; only the first ${MAX_PDF_PAGES} were scanned.`,329 );330 }331 const renderScale = await detectRenderScale(pdf);332333 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {334 onProgress?.(pageNum, pageCount);335336 try {337 const page = await pdf.getPage(pageNum);338 const canvas = await renderPageToCanvas(page, renderScale);339 const ctx = canvas.getContext("2d");340 if (!ctx) {341 errors.push(`Page ${pageNum}: no canvas context`);342 continue;343 }344345 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);346 for (const env of pageEnvelopes) envelopes.push(env);347348 for (const env of pageEnvelopes) {349 const stripe = env.v === 3 ? (env.s ?? 0) : 0;350 351 stripeTotalsForExpected.set(stripe, env.t);352 }353 } catch (err) {354 errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);355 }356 }357358 const metricsEnabled =359 typeof window !== "undefined" &&360 (import.meta.env.MODE === "test" ||361 new URLSearchParams(window.location.search).has("codexMetrics"));362 if (metricsEnabled) {363 const stripeTotals = new Map<number, number>();364 const stripeDecoded = new Map<number, number>();365 for (const env of envelopes) {366 const stripe = env.v === 3 ? (env.s ?? 0) : 0;367 stripeTotals.set(stripe, env.t);368 stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);369 }370 const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);371 const stripeIndices = Array.from(stripeTotals.keys());372 const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;373 (window as Window & {374 __codexImportMetrics?: {375 decodeRate: number;376 shortStripeCount: number;377 perStripeDecoded: number[];378 };379 }).__codexImportMetrics = {380 decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,381 shortStripeCount: Array.from(stripeTotals.entries()).filter(382 ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,383 ).length,384 perStripeDecoded:385 maxStripe >= 0386 ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)387 : [],388 };389 }390391 392 const totalExpected =393 stripeTotalsForExpected.size > 0394 ? Array.from(stripeTotalsForExpected.values()).reduce((sum, t) => sum + t, 0)395 : null;396397 return {398 chunksFound: envelopes.length,399 totalExpected,400 envelopes,401 errors,402 };403}404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433async function scanCanvasGrid(434 canvas: HTMLCanvasElement,435 seenIndices: Set<string>,436 renderScale: number,437): Promise<ChunkEnvelope[]> {438 const found: ChunkEnvelope[] = [];439440 441 442 const cellCanvas = document.createElement("canvas");443 const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });444 if (!cellCtx) return found;445446 for (let row = 0; row < CODES_PER_COL; row++) {447 for (let col = 0; col < CODES_PER_ROW; col++) {448 const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);449 if (sw <= 0 || sh <= 0) continue;450451 cellCanvas.width = sw;452 cellCanvas.height = sh;453 454 455 456 cellCtx.fillStyle = "white";457 cellCtx.fillRect(0, 0, sw, sh);458 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);459460 const text = decodeAztecCellRobust(461 cellCanvas,462 cellCtx,463 { x: 0, y: 0, w: sw, h: sh },464 renderScale,465 );466 if (!text) continue;467468 const envelope = parseChunkEnvelope(text);469 if (!envelope) continue;470 const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;471 if (seenIndices.has(stripeKey)) continue;472 if (!(await verifyChunkIntegrity(envelope))) continue;473474 seenIndices.add(stripeKey);475 found.push(envelope);476 }477 }478479 return found;480}481482function decodeAztecCellRobust(483 canvas: HTMLCanvasElement,484 ctx: CanvasRenderingContext2D,485 cellRect: { x: number; y: number; w: number; h: number },486 renderScale: number,487): string | null {488 const strategies = getCellDecodeStrategies(renderScale);489 for (const strategy of strategies) {490 const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);491 if (result !== null) {492 return result;493 }494 }495 return null;496}497498function decodeAztecCellWithStrategy(499 canvas: HTMLCanvasElement,500 ctx: CanvasRenderingContext2D,501 cellRect: { x: number; y: number; w: number; h: number },502 strategy: CellDecodeStrategy,503): string | null {504 const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);505 if (w <= 0 || h <= 0) return null;506507 const imageData = ctx.getImageData(x, y, w, h);508 const processedData = strategy.normalize509 ? normalizeToBlackWhite(imageData)510 : imageData;511512 const argb = imageDataToArgb(processedData);513 const { buffer, width, height } =514 strategy.upscale > 1515 ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)516 : { buffer: argb, width: processedData.width, height: processedData.height };517518 return decodeAztecFromArgb(buffer, width, height);519}520521function applyCropFactor(522 rect: { x: number; y: number; w: number; h: number },523 factor: number,524 canvasW: number,525 canvasH: number,526): { x: number; y: number; w: number; h: number } {527 const cx = rect.x + rect.w / 2;528 const cy = rect.y + rect.h / 2;529 const nw = Math.max(1, Math.round(rect.w * factor));530 const nh = Math.max(1, Math.round(rect.h * factor));531 const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));532 const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));533 const w = Math.max(1, Math.min(canvasW - x, nw));534 const h = Math.max(1, Math.min(canvasH - y, nh));535 return { x, y, w, h };536}537538function normalizeToBlackWhite(imageData: ImageData): ImageData {539 const data = new Uint8ClampedArray(imageData.data);540 for (let i = 0; i < data.length; i += 4) {541 const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;542 const v = lum > 128 ? 255 : 0;543 data[i] = v;544 data[i + 1] = v;545 data[i + 2] = v;546 data[i + 3] = 255;547 }548 return new ImageData(data, imageData.width, imageData.height);549}550551function upscaleArgb(552 argb: Int32Array,553 width: number,554 height: number,555 scale: number,556): { buffer: Int32Array; width: number; height: number } {557 const targetW = Math.max(1, Math.round(width * scale));558 const targetH = Math.max(1, Math.round(height * scale));559 const out = new Int32Array(targetW * targetH);560 for (let y = 0; y < targetH; y++) {561 const srcY = Math.min(height - 1, Math.floor(y / scale));562 for (let x = 0; x < targetW; x++) {563 const srcX = Math.min(width - 1, Math.floor(x / scale));564 out[y * targetW + x] = argb[srcY * width + srcX]!;565 }566 }567 return { buffer: out, width: targetW, height: targetH };568}569570571572573574575576577578579580export function cellRectFor(581 canvasW: number,582 canvasH: number,583 row: number,584 col: number,585): { sx: number; sy: number; sw: number; sh: number } {586 587 588 const scaleX = canvasW / PAGE_WIDTH;589 const scaleY = canvasH / PAGE_HEIGHT;590591 592 593 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;594 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;595596 const codeWpx = CODE_SIZE * scaleX;597 const codeHpx = CODE_SIZE * scaleY;598 const quietX = Math.round(codeWpx * 0.12);599 const quietY = Math.round(codeHpx * 0.12);600601 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;602 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;603604 const sx = Math.max(0, Math.floor(codeX - quietX));605 const sy = Math.max(0, Math.floor(codeY - quietY));606 const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));607 const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));608 return { sx, sy, sw, sh };609}610611612613614615616export function imageDataToArgb(imageData: ImageData): Int32Array {617 const raw = imageData.data;618 const out = new Int32Array(imageData.width * imageData.height);619 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {620 out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;621 }622 return out;623}624625626627628629630631632633634635636637638639640641642export function decodeAztecFromArgb(643 argb: Int32Array,644 width: number,645 height: number,646): string | null {647 const source = new RGBLuminanceSource(argb, width, height);648649 try {650 const bitmap = new BinaryBitmap(new HybridBinarizer(source));651 return new AztecCodeReader().decode(bitmap).getText();652 } catch (err) {653 if (!(err instanceof NotFoundException)) return null;654 }655656 try {657 const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));658 return new AztecCodeReader().decode(bitmap).getText();659 } catch {660 return null;661 }662}663664665666667668669670671export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {672 let parsed: unknown;673 try {674 parsed = JSON.parse(raw);675 } catch {676 return null;677 }678 if (typeof parsed !== "object" || parsed === null) return null;679 const obj = parsed as Record<string, unknown>;680681 if (obj.v !== 3) return null;682683 const baseShapeOk =684 typeof obj.c === "string" &&685 Number.isInteger(obj.i) && 686 Number.isInteger(obj.t) &&687 typeof obj.p === "boolean" &&688 typeof obj.d === "string" &&689 typeof obj.h === "string";690 if (!baseShapeOk) return null;691692 if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {693 return null;694 }695696 return obj as unknown as ChunkEnvelope;697}