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 let totalExpected: number | null = null;292293 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;294 try {295 const arrayBuffer = await file.arrayBuffer();296 297 298 299 300 pdf = await pdfjsLib.getDocument({301 data: arrayBuffer,302 303 304 305 isEvalSupported: false,306 disableFontFace: true, 307 disableRange: true, 308 disableStream: true, 309 } as Parameters<typeof pdfjsLib.getDocument>[0]).promise;310 } catch (err) {311 return {312 chunksFound: 0,313 totalExpected: null,314 envelopes,315 errors: [316 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,317 ],318 };319 }320321 322 323 const declaredPages = pdf.numPages;324 const pageCount = Math.min(declaredPages, MAX_PDF_PAGES);325 if (declaredPages > MAX_PDF_PAGES) {326 errors.push(327 `PDF has ${declaredPages} pages; only the first ${MAX_PDF_PAGES} were scanned.`,328 );329 }330 const renderScale = await detectRenderScale(pdf);331332 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {333 onProgress?.(pageNum, pageCount);334335 try {336 const page = await pdf.getPage(pageNum);337 const canvas = await renderPageToCanvas(page, renderScale);338 const ctx = canvas.getContext("2d");339 if (!ctx) {340 errors.push(`Page ${pageNum}: no canvas context`);341 continue;342 }343344 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);345 for (const env of pageEnvelopes) envelopes.push(env);346347 if (totalExpected === null && pageEnvelopes.length > 0) {348 totalExpected = pageEnvelopes[0]!.t;349 }350 } catch (err) {351 errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);352 }353 }354355 const metricsEnabled =356 typeof window !== "undefined" &&357 (import.meta.env.MODE === "test" ||358 new URLSearchParams(window.location.search).has("codexMetrics"));359 if (metricsEnabled) {360 const stripeTotals = new Map<number, number>();361 const stripeDecoded = new Map<number, number>();362 for (const env of envelopes) {363 const stripe = env.v === 3 ? (env.s ?? 0) : 0;364 stripeTotals.set(stripe, env.t);365 stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);366 }367 const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);368 const stripeIndices = Array.from(stripeTotals.keys());369 const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;370 (window as Window & {371 __codexImportMetrics?: {372 decodeRate: number;373 shortStripeCount: number;374 perStripeDecoded: number[];375 };376 }).__codexImportMetrics = {377 decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,378 shortStripeCount: Array.from(stripeTotals.entries()).filter(379 ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,380 ).length,381 perStripeDecoded:382 maxStripe >= 0383 ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)384 : [],385 };386 }387388 return {389 chunksFound: envelopes.length,390 totalExpected,391 envelopes,392 errors,393 };394}395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424async function scanCanvasGrid(425 canvas: HTMLCanvasElement,426 seenIndices: Set<string>,427 renderScale: number,428): Promise<ChunkEnvelope[]> {429 const found: ChunkEnvelope[] = [];430431 432 433 const cellCanvas = document.createElement("canvas");434 const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });435 if (!cellCtx) return found;436437 for (let row = 0; row < CODES_PER_COL; row++) {438 for (let col = 0; col < CODES_PER_ROW; col++) {439 const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);440 if (sw <= 0 || sh <= 0) continue;441442 cellCanvas.width = sw;443 cellCanvas.height = sh;444 445 446 447 cellCtx.fillStyle = "white";448 cellCtx.fillRect(0, 0, sw, sh);449 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);450451 const text = decodeAztecCellRobust(452 cellCanvas,453 cellCtx,454 { x: 0, y: 0, w: sw, h: sh },455 renderScale,456 );457 if (!text) continue;458459 const envelope = parseChunkEnvelope(text);460 if (!envelope) continue;461 const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;462 if (seenIndices.has(stripeKey)) continue;463 if (!(await verifyChunkIntegrity(envelope))) continue;464465 seenIndices.add(stripeKey);466 found.push(envelope);467 }468 }469470 return found;471}472473function decodeAztecCellRobust(474 canvas: HTMLCanvasElement,475 ctx: CanvasRenderingContext2D,476 cellRect: { x: number; y: number; w: number; h: number },477 renderScale: number,478): string | null {479 const strategies = getCellDecodeStrategies(renderScale);480 for (const strategy of strategies) {481 const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);482 if (result !== null) {483 return result;484 }485 }486 return null;487}488489function decodeAztecCellWithStrategy(490 canvas: HTMLCanvasElement,491 ctx: CanvasRenderingContext2D,492 cellRect: { x: number; y: number; w: number; h: number },493 strategy: CellDecodeStrategy,494): string | null {495 const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);496 if (w <= 0 || h <= 0) return null;497498 const imageData = ctx.getImageData(x, y, w, h);499 const processedData = strategy.normalize500 ? normalizeToBlackWhite(imageData)501 : imageData;502503 const argb = imageDataToArgb(processedData);504 const { buffer, width, height } =505 strategy.upscale > 1506 ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)507 : { buffer: argb, width: processedData.width, height: processedData.height };508509 return decodeAztecFromArgb(buffer, width, height);510}511512function applyCropFactor(513 rect: { x: number; y: number; w: number; h: number },514 factor: number,515 canvasW: number,516 canvasH: number,517): { x: number; y: number; w: number; h: number } {518 const cx = rect.x + rect.w / 2;519 const cy = rect.y + rect.h / 2;520 const nw = Math.max(1, Math.round(rect.w * factor));521 const nh = Math.max(1, Math.round(rect.h * factor));522 const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));523 const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));524 const w = Math.max(1, Math.min(canvasW - x, nw));525 const h = Math.max(1, Math.min(canvasH - y, nh));526 return { x, y, w, h };527}528529function normalizeToBlackWhite(imageData: ImageData): ImageData {530 const data = new Uint8ClampedArray(imageData.data);531 for (let i = 0; i < data.length; i += 4) {532 const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;533 const v = lum > 128 ? 255 : 0;534 data[i] = v;535 data[i + 1] = v;536 data[i + 2] = v;537 data[i + 3] = 255;538 }539 return new ImageData(data, imageData.width, imageData.height);540}541542function upscaleArgb(543 argb: Int32Array,544 width: number,545 height: number,546 scale: number,547): { buffer: Int32Array; width: number; height: number } {548 const targetW = Math.max(1, Math.round(width * scale));549 const targetH = Math.max(1, Math.round(height * scale));550 const out = new Int32Array(targetW * targetH);551 for (let y = 0; y < targetH; y++) {552 const srcY = Math.min(height - 1, Math.floor(y / scale));553 for (let x = 0; x < targetW; x++) {554 const srcX = Math.min(width - 1, Math.floor(x / scale));555 out[y * targetW + x] = argb[srcY * width + srcX]!;556 }557 }558 return { buffer: out, width: targetW, height: targetH };559}560561562563564565566567568569570571export function cellRectFor(572 canvasW: number,573 canvasH: number,574 row: number,575 col: number,576): { sx: number; sy: number; sw: number; sh: number } {577 578 579 const scaleX = canvasW / PAGE_WIDTH;580 const scaleY = canvasH / PAGE_HEIGHT;581582 583 584 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;585 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;586587 const codeWpx = CODE_SIZE * scaleX;588 const codeHpx = CODE_SIZE * scaleY;589 const quietX = Math.round(codeWpx * 0.12);590 const quietY = Math.round(codeHpx * 0.12);591592 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;593 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;594595 const sx = Math.max(0, Math.floor(codeX - quietX));596 const sy = Math.max(0, Math.floor(codeY - quietY));597 const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));598 const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));599 return { sx, sy, sw, sh };600}601602603604605606607export function imageDataToArgb(imageData: ImageData): Int32Array {608 const raw = imageData.data;609 const out = new Int32Array(imageData.width * imageData.height);610 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {611 out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;612 }613 return out;614}615616617618619620621622623624625626627628629630631632633export function decodeAztecFromArgb(634 argb: Int32Array,635 width: number,636 height: number,637): string | null {638 const source = new RGBLuminanceSource(argb, width, height);639640 try {641 const bitmap = new BinaryBitmap(new HybridBinarizer(source));642 return new AztecCodeReader().decode(bitmap).getText();643 } catch (err) {644 if (!(err instanceof NotFoundException)) return null;645 }646647 try {648 const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));649 return new AztecCodeReader().decode(bitmap).getText();650 } catch {651 return null;652 }653}654655656657658659660661662663664665export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {666 let parsed: unknown;667 try {668 parsed = JSON.parse(raw);669 } catch {670 return null;671 }672 if (typeof parsed !== "object" || parsed === null) return null;673 const obj = parsed as Record<string, unknown>;674675 if (obj.v !== 2 && obj.v !== 3) return null;676677 const baseShapeOk =678 typeof obj.c === "string" &&679 Number.isInteger(obj.i) && 680 Number.isInteger(obj.t) &&681 typeof obj.p === "boolean" &&682 typeof obj.d === "string" &&683 typeof obj.h === "string";684 if (!baseShapeOk) return null;685686 687 if (obj.v === 3 && (!Number.isInteger(obj.s) || !Number.isInteger(obj.S))) {688 return null;689 }690691 return obj as unknown as ChunkEnvelope;692}