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);125const MAX_PDF_RENDER_PIXELS = 2_000_000_000;126127export interface PDFRenderBudget {128 remainingPixels: number;129}130131class PDFRenderBudgetError extends Error {}132133export function createPDFRenderBudget(maxPixels = MAX_PDF_RENDER_PIXELS): PDFRenderBudget {134 return { remainingPixels: maxPixels };135}136137export function claimPDFRenderPixels(138 budget: PDFRenderBudget,139 width: number,140 height: number,141): void {142 const pixels = width * height;143 if (pixels > budget.remainingPixels) {144 throw new PDFRenderBudgetError("PDF aggregate render limit exceeded");145 }146 budget.remainingPixels -= pixels;147}148149interface CellDecodeStrategy {150 cropFactor: number;151 normalize: boolean;152 upscale: number;153}154155const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [156 { cropFactor: 1.0, normalize: false, upscale: 1.0 },157 { cropFactor: 0.88, normalize: false, upscale: 1.0 },158 { cropFactor: 1.12, normalize: false, upscale: 1.0 },159 { cropFactor: 1.0, normalize: true, upscale: 1.0 },160 { cropFactor: 0.88, normalize: true, upscale: 1.0 },161 { cropFactor: 1.0, normalize: false, upscale: 2.0 },162 { cropFactor: 1.0, normalize: true, upscale: 2.0 },163];164165const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [166 { cropFactor: 1.0, normalize: false, upscale: 1.0 },167 { cropFactor: 1.12, normalize: false, upscale: 1.0 },168 { cropFactor: 0.88, normalize: false, upscale: 1.0 },169 { cropFactor: 1.0, normalize: true, upscale: 1.0 },170 { cropFactor: 1.12, normalize: true, upscale: 1.0 },171 { cropFactor: 1.0, normalize: false, upscale: 2.0 },172 { cropFactor: 1.0, normalize: true, upscale: 2.0 },173];174175function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {176 return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;177}178179180181182183184185186187188189190async function detectRenderScale(191 pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,192 budget: PDFRenderBudget,193): Promise<number> {194 let bestScale: number = CANDIDATE_SCALES[0];195 let bestRate = -1;196 const probePages = Math.min(3, pdf.numPages);197198 for (const scale of CANDIDATE_SCALES) {199 try {200 let decoded = 0;201 let total = 0;202 for (let pageNum = 1; pageNum <= probePages; pageNum++) {203 const page = await pdf.getPage(pageNum);204 const result = await probePageDecodeRate(page, scale, budget);205 decoded += result.decoded;206 total += result.total;207 }208 const rate = total > 0 ? decoded / total : 0;209 if (rate > bestRate) {210 bestRate = rate;211 bestScale = scale;212 }213 if (rate >= 0.99) {214 return scale;215 }216 } catch (error) {217 if (error instanceof PDFRenderBudgetError) throw error;218 219 }220 }221222 return bestScale;223}224225async function probePageDecodeRate(226 page: PdfPage,227 scale: number,228 budget: PDFRenderBudget,229): Promise<{ decoded: number; total: number }> {230 const canvas = await renderPageToCanvas(page, scale, budget);231 try {232 const ctx = canvas.getContext("2d");233 if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };234235 let decoded = 0;236 for (let row = 0; row < CODES_PER_COL; row++) {237 for (let col = 0; col < CODES_PER_ROW; col++) {238 const rect = cellRectFor(canvas.width, canvas.height, row, col);239 if (!rect.sw || !rect.sh) continue;240 const text = decodeAztecCellRobust(241 canvas,242 ctx,243 { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },244 scale,245 );246 if (text !== null) decoded++;247 }248 }249 return { decoded, total: CODES_PER_PAGE };250 } finally {251 canvas.width = 0;252 canvas.height = 0;253 }254}255256async function renderPageToCanvas(257 page: PdfPage,258 scale: number,259 budget: PDFRenderBudget,260): Promise<HTMLCanvasElement> {261 const viewport = page.getViewport({ scale });262 263 264 265 266 const w = Math.ceil(viewport.width);267 const h = Math.ceil(viewport.height);268 if (w > MAX_RENDERED_DIMENSION || h > MAX_RENDERED_DIMENSION) {269 throw new Error(270 `page dimensions ${w}×${h}px exceed the ${MAX_RENDERED_DIMENSION}px render limit`,271 );272 }273 claimPDFRenderPixels(budget, w, h);274 const canvas = document.createElement("canvas");275 canvas.width = w;276 canvas.height = h;277 const ctx = canvas.getContext("2d");278 if (!ctx) {279 return canvas;280 }281 ctx.fillStyle = "white";282 ctx.fillRect(0, 0, canvas.width, canvas.height);283 await page.render({ canvas, canvasContext: ctx, viewport }).promise;284 return canvas;285}286287288289290291292293294295296297export async function importCodexPDF(298 file: File,299 onProgress?: PDFImportProgress,300): Promise<PDFImportResult> {301 302 303 304 if (file.size > IMPORT_LIMITS.MAX_PDF_BYTES) {305 return {306 chunksFound: 0,307 totalExpected: null,308 envelopes: [],309 errors: [310 `PDF is ${(file.size / (1024 * 1024)).toFixed(1)} MiB — Codex PDFs are limited to ${IMPORT_LIMITS.MAX_PDF_BYTES / (1024 * 1024)} MiB.`,311 ],312 };313 }314315 const pdfjsLib = await getPdfJs();316317 const envelopes: ChunkEnvelope[] = [];318 const errors: string[] = [];319 320 321 const seenIndices = new Set<string>();322 323 324 325 const stripeTotalsForExpected = new Map<number, number>();326 const renderBudget = createPDFRenderBudget();327328 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;329 try {330 const arrayBuffer = await file.arrayBuffer();331 332 333 334 335 pdf = await pdfjsLib.getDocument({336 data: arrayBuffer,337 338 339 340 isEvalSupported: false,341 disableFontFace: true, 342 disableRange: true, 343 disableStream: true, 344 } as Parameters<typeof pdfjsLib.getDocument>[0]).promise;345 } catch (err) {346 return {347 chunksFound: 0,348 totalExpected: null,349 envelopes,350 errors: [351 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,352 ],353 };354 }355356 357 358 const declaredPages = pdf.numPages;359 const pageCount = Math.min(declaredPages, MAX_PDF_PAGES);360 if (declaredPages > MAX_PDF_PAGES) {361 errors.push(362 `PDF has ${declaredPages} pages; only the first ${MAX_PDF_PAGES} were scanned.`,363 );364 }365 let renderScale: number;366 try {367 renderScale = await detectRenderScale(pdf, renderBudget);368 } catch (error) {369 errors.push(error instanceof Error ? error.message : String(error));370 return { chunksFound: 0, totalExpected: null, envelopes, errors };371 }372373 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {374 onProgress?.(pageNum, pageCount);375376 let canvas: HTMLCanvasElement | undefined;377 try {378 const page = await pdf.getPage(pageNum);379 canvas = await renderPageToCanvas(page, renderScale, renderBudget);380 const ctx = canvas.getContext("2d");381 if (!ctx) {382 errors.push(`Page ${pageNum}: no canvas context`);383 continue;384 }385386 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);387 for (const env of pageEnvelopes) envelopes.push(env);388389 for (const env of pageEnvelopes) {390 const stripe = env.v === 3 ? (env.s ?? 0) : 0;391 392 stripeTotalsForExpected.set(stripe, env.t);393 }394 } catch (err) {395 errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);396 if (err instanceof PDFRenderBudgetError) break;397 } finally {398 if (canvas) {399 canvas.width = 0;400 canvas.height = 0;401 }402 }403 }404405 const metricsEnabled =406 typeof window !== "undefined" &&407 (import.meta.env.MODE === "test" ||408 new URLSearchParams(window.location.search).has("codexMetrics"));409 if (metricsEnabled) {410 const stripeTotals = new Map<number, number>();411 const stripeDecoded = new Map<number, number>();412 for (const env of envelopes) {413 const stripe = env.v === 3 ? (env.s ?? 0) : 0;414 stripeTotals.set(stripe, env.t);415 stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);416 }417 const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);418 const stripeIndices = Array.from(stripeTotals.keys());419 const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;420 (window as Window & {421 __codexImportMetrics?: {422 decodeRate: number;423 shortStripeCount: number;424 perStripeDecoded: number[];425 };426 }).__codexImportMetrics = {427 decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,428 shortStripeCount: Array.from(stripeTotals.entries()).filter(429 ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,430 ).length,431 perStripeDecoded:432 maxStripe >= 0433 ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)434 : [],435 };436 }437438 439 const totalExpected =440 stripeTotalsForExpected.size > 0441 ? Array.from(stripeTotalsForExpected.values()).reduce((sum, t) => sum + t, 0)442 : null;443444 return {445 chunksFound: envelopes.length,446 totalExpected,447 envelopes,448 errors,449 };450}451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480async function scanCanvasGrid(481 canvas: HTMLCanvasElement,482 seenIndices: Set<string>,483 renderScale: number,484): Promise<ChunkEnvelope[]> {485 const found: ChunkEnvelope[] = [];486487 488 489 const cellCanvas = document.createElement("canvas");490 const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });491 if (!cellCtx) return found;492493 for (let row = 0; row < CODES_PER_COL; row++) {494 for (let col = 0; col < CODES_PER_ROW; col++) {495 const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);496 if (sw <= 0 || sh <= 0) continue;497498 cellCanvas.width = sw;499 cellCanvas.height = sh;500 501 502 503 cellCtx.fillStyle = "white";504 cellCtx.fillRect(0, 0, sw, sh);505 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);506507 const text = decodeAztecCellRobust(508 cellCanvas,509 cellCtx,510 { x: 0, y: 0, w: sw, h: sh },511 renderScale,512 );513 if (!text) continue;514515 const envelope = parseChunkEnvelope(text);516 if (!envelope) continue;517 const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;518 if (seenIndices.has(stripeKey)) continue;519 if (!(await verifyChunkIntegrity(envelope))) continue;520521 seenIndices.add(stripeKey);522 found.push(envelope);523 }524 }525526 return found;527}528529function decodeAztecCellRobust(530 canvas: HTMLCanvasElement,531 ctx: CanvasRenderingContext2D,532 cellRect: { x: number; y: number; w: number; h: number },533 renderScale: number,534): string | null {535 const strategies = getCellDecodeStrategies(renderScale);536 for (const strategy of strategies) {537 const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);538 if (result !== null) {539 return result;540 }541 }542 return null;543}544545function decodeAztecCellWithStrategy(546 canvas: HTMLCanvasElement,547 ctx: CanvasRenderingContext2D,548 cellRect: { x: number; y: number; w: number; h: number },549 strategy: CellDecodeStrategy,550): string | null {551 const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);552 if (w <= 0 || h <= 0) return null;553554 const imageData = ctx.getImageData(x, y, w, h);555 const processedData = strategy.normalize556 ? normalizeToBlackWhite(imageData)557 : imageData;558559 const argb = imageDataToArgb(processedData);560 const { buffer, width, height } =561 strategy.upscale > 1562 ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)563 : { buffer: argb, width: processedData.width, height: processedData.height };564565 return decodeAztecFromArgb(buffer, width, height);566}567568function applyCropFactor(569 rect: { x: number; y: number; w: number; h: number },570 factor: number,571 canvasW: number,572 canvasH: number,573): { x: number; y: number; w: number; h: number } {574 const cx = rect.x + rect.w / 2;575 const cy = rect.y + rect.h / 2;576 const nw = Math.max(1, Math.round(rect.w * factor));577 const nh = Math.max(1, Math.round(rect.h * factor));578 const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));579 const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));580 const w = Math.max(1, Math.min(canvasW - x, nw));581 const h = Math.max(1, Math.min(canvasH - y, nh));582 return { x, y, w, h };583}584585function normalizeToBlackWhite(imageData: ImageData): ImageData {586 const data = new Uint8ClampedArray(imageData.data);587 for (let i = 0; i < data.length; i += 4) {588 const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;589 const v = lum > 128 ? 255 : 0;590 data[i] = v;591 data[i + 1] = v;592 data[i + 2] = v;593 data[i + 3] = 255;594 }595 return new ImageData(data, imageData.width, imageData.height);596}597598function upscaleArgb(599 argb: Int32Array,600 width: number,601 height: number,602 scale: number,603): { buffer: Int32Array; width: number; height: number } {604 const targetW = Math.max(1, Math.round(width * scale));605 const targetH = Math.max(1, Math.round(height * scale));606 const out = new Int32Array(targetW * targetH);607 for (let y = 0; y < targetH; y++) {608 const srcY = Math.min(height - 1, Math.floor(y / scale));609 for (let x = 0; x < targetW; x++) {610 const srcX = Math.min(width - 1, Math.floor(x / scale));611 out[y * targetW + x] = argb[srcY * width + srcX]!;612 }613 }614 return { buffer: out, width: targetW, height: targetH };615}616617618619620621622623624625626627export function cellRectFor(628 canvasW: number,629 canvasH: number,630 row: number,631 col: number,632): { sx: number; sy: number; sw: number; sh: number } {633 634 635 const scaleX = canvasW / PAGE_WIDTH;636 const scaleY = canvasH / PAGE_HEIGHT;637638 639 640 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;641 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;642643 const codeWpx = CODE_SIZE * scaleX;644 const codeHpx = CODE_SIZE * scaleY;645 const quietX = Math.round(codeWpx * 0.12);646 const quietY = Math.round(codeHpx * 0.12);647648 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;649 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;650651 const sx = Math.max(0, Math.floor(codeX - quietX));652 const sy = Math.max(0, Math.floor(codeY - quietY));653 const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));654 const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));655 return { sx, sy, sw, sh };656}657658659660661662663export function imageDataToArgb(imageData: ImageData): Int32Array {664 const raw = imageData.data;665 const out = new Int32Array(imageData.width * imageData.height);666 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {667 out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;668 }669 return out;670}671672673674675676677678679680681682683684685686687688689export function decodeAztecFromArgb(690 argb: Int32Array,691 width: number,692 height: number,693): string | null {694 const source = new RGBLuminanceSource(argb, width, height);695696 try {697 const bitmap = new BinaryBitmap(new HybridBinarizer(source));698 return new AztecCodeReader().decode(bitmap).getText();699 } catch (err) {700 if (!(err instanceof NotFoundException)) return null;701 }702703 try {704 const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));705 return new AztecCodeReader().decode(bitmap).getText();706 } catch {707 return null;708 }709}710711712713714715716717718export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {719 let parsed: unknown;720 try {721 parsed = JSON.parse(raw);722 } catch {723 return null;724 }725 if (typeof parsed !== "object" || parsed === null) return null;726 const obj = parsed as Record<string, unknown>;727728 if (obj.v !== 3) return null;729730 const baseShapeOk =731 typeof obj.c === "string" &&732 Number.isInteger(obj.i) && 733 Number.isInteger(obj.t) &&734 typeof obj.p === "boolean" &&735 typeof obj.d === "string" &&736 typeof obj.h === "string";737 if (!baseShapeOk) return null;738739 if (!Number.isInteger(obj.s) || !Number.isInteger(obj.S)) {740 return null;741 }742743 return obj as unknown as ChunkEnvelope;744}