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 {43 CODE_SIZE,44 COL_GAP,45 HEADER_GAP,46 HEADER_HEIGHT,47 MARGIN,48 PAGE_HEIGHT,49 PAGE_WIDTH,50 ROW_STEP,51} from "./pdf-layout";5253545556575859type PdfJsModule = typeof import("pdfjs-dist");60type PdfPage = any;61let pdfJsPromise: Promise<PdfJsModule> | null = null;6263async function getPdfJs(): Promise<PdfJsModule> {64 if (!pdfJsPromise) {65 pdfJsPromise = import("pdfjs-dist").then((mod) => {66 if (67 typeof mod.GlobalWorkerOptions.workerSrc !== "string" ||68 mod.GlobalWorkerOptions.workerSrc.length === 069 ) {70 if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {71 mod.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL(72 "pdf.worker.min.mjs",73 );74 } else {75 mod.GlobalWorkerOptions.workerSrc = "./pdf.worker.min.mjs";76 }77 }78 return mod;79 });80 }81 return pdfJsPromise;82}8384export interface PDFImportResult {85 86 chunksFound: number;87 88 totalExpected: number | null;89 90 envelopes: ChunkEnvelope[];91 92 errors: string[];93}9495export interface PDFImportProgress {96 (page: number, totalPages: number): void;97}9899100101102103104105106107108109110111112113114const CANDIDATE_SCALES = [3.95, 5.25, 6.05] as const;115116interface CellDecodeStrategy {117 cropFactor: number;118 normalize: boolean;119 upscale: number;120}121122const SMALL_SCALE_STRATEGIES: CellDecodeStrategy[] = [123 { cropFactor: 1.0, normalize: false, upscale: 1.0 },124 { cropFactor: 0.88, normalize: false, upscale: 1.0 },125 { cropFactor: 1.12, normalize: false, upscale: 1.0 },126 { cropFactor: 1.0, normalize: true, upscale: 1.0 },127 { cropFactor: 0.88, normalize: true, upscale: 1.0 },128 { cropFactor: 1.0, normalize: false, upscale: 2.0 },129 { cropFactor: 1.0, normalize: true, upscale: 2.0 },130];131132const LARGE_SCALE_STRATEGIES: CellDecodeStrategy[] = [133 { cropFactor: 1.0, normalize: false, upscale: 1.0 },134 { cropFactor: 1.12, normalize: false, upscale: 1.0 },135 { cropFactor: 0.88, normalize: false, upscale: 1.0 },136 { cropFactor: 1.0, normalize: true, upscale: 1.0 },137 { cropFactor: 1.12, normalize: true, upscale: 1.0 },138 { cropFactor: 1.0, normalize: false, upscale: 2.0 },139 { cropFactor: 1.0, normalize: true, upscale: 2.0 },140];141142function getCellDecodeStrategies(renderScale: number): CellDecodeStrategy[] {143 return renderScale < 4.5 ? SMALL_SCALE_STRATEGIES : LARGE_SCALE_STRATEGIES;144}145146147148149150151152153154155156157async function detectRenderScale(158 pdf: Awaited<ReturnType<PdfJsModule["getDocument"]>["promise"]>,159): Promise<number> {160 let bestScale: number = CANDIDATE_SCALES[0];161 let bestRate = -1;162 const probePages = Math.min(3, pdf.numPages);163164 for (const scale of CANDIDATE_SCALES) {165 try {166 let decoded = 0;167 let total = 0;168 for (let pageNum = 1; pageNum <= probePages; pageNum++) {169 const page = await pdf.getPage(pageNum);170 const result = await probePageDecodeRate(page, scale);171 decoded += result.decoded;172 total += result.total;173 }174 const rate = total > 0 ? decoded / total : 0;175 if (rate > bestRate) {176 bestRate = rate;177 bestScale = scale;178 }179 if (rate >= 0.99) {180 return scale;181 }182 } catch {183 184 }185 }186187 return bestScale;188}189190async function probePageDecodeRate(191 page: PdfPage,192 scale: number,193): Promise<{ decoded: number; total: number }> {194 const canvas = await renderPageToCanvas(page, scale);195 const ctx = canvas.getContext("2d");196 if (!ctx) return { decoded: 0, total: CODES_PER_PAGE };197198 let decoded = 0;199 for (let row = 0; row < CODES_PER_COL; row++) {200 for (let col = 0; col < CODES_PER_ROW; col++) {201 const rect = cellRectFor(canvas.width, canvas.height, row, col);202 if (!rect.sw || !rect.sh) continue;203 const text = decodeAztecCellRobust(204 canvas,205 ctx,206 { x: rect.sx, y: rect.sy, w: rect.sw, h: rect.sh },207 scale,208 );209 if (text !== null) decoded++;210 }211 }212 return { decoded, total: CODES_PER_PAGE };213}214215async function renderPageToCanvas(216 page: PdfPage,217 scale: number,218): Promise<HTMLCanvasElement> {219 const viewport = page.getViewport({ scale });220 const canvas = document.createElement("canvas");221 canvas.width = Math.ceil(viewport.width);222 canvas.height = Math.ceil(viewport.height);223 const ctx = canvas.getContext("2d");224 if (!ctx) {225 return canvas;226 }227 ctx.fillStyle = "white";228 ctx.fillRect(0, 0, canvas.width, canvas.height);229 await page.render({ canvas, canvasContext: ctx, viewport }).promise;230 return canvas;231}232233234235236237238239240241242243export async function importCodexPDF(244 file: File,245 onProgress?: PDFImportProgress,246): Promise<PDFImportResult> {247 const pdfjsLib = await getPdfJs();248249 const envelopes: ChunkEnvelope[] = [];250 const errors: string[] = [];251 252 253 const seenIndices = new Set<string>();254 let totalExpected: number | null = null;255256 let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;257 try {258 const arrayBuffer = await file.arrayBuffer();259 pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;260 } catch (err) {261 return {262 chunksFound: 0,263 totalExpected: null,264 envelopes,265 errors: [266 `Could not open PDF: ${err instanceof Error ? err.message : String(err)}`,267 ],268 };269 }270271 const pageCount = pdf.numPages;272 const renderScale = await detectRenderScale(pdf);273274 for (let pageNum = 1; pageNum <= pageCount; pageNum++) {275 onProgress?.(pageNum, pageCount);276277 try {278 const page = await pdf.getPage(pageNum);279 const canvas = await renderPageToCanvas(page, renderScale);280 const ctx = canvas.getContext("2d");281 if (!ctx) {282 errors.push(`Page ${pageNum}: no canvas context`);283 continue;284 }285286 const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);287 for (const env of pageEnvelopes) envelopes.push(env);288289 if (totalExpected === null && pageEnvelopes.length > 0) {290 totalExpected = pageEnvelopes[0]!.t;291 }292 } catch (err) {293 errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);294 }295 }296297 const metricsEnabled =298 typeof window !== "undefined" &&299 (import.meta.env.MODE === "test" ||300 new URLSearchParams(window.location.search).has("codexMetrics"));301 if (metricsEnabled) {302 const stripeTotals = new Map<number, number>();303 const stripeDecoded = new Map<number, number>();304 for (const env of envelopes) {305 const stripe = env.v === 3 ? (env.s ?? 0) : 0;306 stripeTotals.set(stripe, env.t);307 stripeDecoded.set(stripe, (stripeDecoded.get(stripe) ?? 0) + 1);308 }309 const totalPrinted = Array.from(stripeTotals.values()).reduce((sum, total) => sum + total, 0);310 const stripeIndices = Array.from(stripeTotals.keys());311 const maxStripe = stripeIndices.length > 0 ? Math.max(...stripeIndices) : -1;312 (window as Window & {313 __codexImportMetrics?: {314 decodeRate: number;315 shortStripeCount: number;316 perStripeDecoded: number[];317 };318 }).__codexImportMetrics = {319 decodeRate: totalPrinted > 0 ? envelopes.length / totalPrinted : 0,320 shortStripeCount: Array.from(stripeTotals.entries()).filter(321 ([stripe, total]) => (stripeDecoded.get(stripe) ?? 0) < total,322 ).length,323 perStripeDecoded:324 maxStripe >= 0325 ? Array.from({ length: maxStripe + 1 }, (_, stripe) => stripeDecoded.get(stripe) ?? 0)326 : [],327 };328 }329330 return {331 chunksFound: envelopes.length,332 totalExpected,333 envelopes,334 errors,335 };336}337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366async function scanCanvasGrid(367 canvas: HTMLCanvasElement,368 seenIndices: Set<string>,369 renderScale: number,370): Promise<ChunkEnvelope[]> {371 const found: ChunkEnvelope[] = [];372373 374 375 const cellCanvas = document.createElement("canvas");376 const cellCtx = cellCanvas.getContext("2d", { willReadFrequently: true });377 if (!cellCtx) return found;378379 for (let row = 0; row < CODES_PER_COL; row++) {380 for (let col = 0; col < CODES_PER_ROW; col++) {381 const { sx, sy, sw, sh } = cellRectFor(canvas.width, canvas.height, row, col);382 if (sw <= 0 || sh <= 0) continue;383384 cellCanvas.width = sw;385 cellCanvas.height = sh;386 387 388 389 cellCtx.fillStyle = "white";390 cellCtx.fillRect(0, 0, sw, sh);391 cellCtx.drawImage(canvas, sx, sy, sw, sh, 0, 0, sw, sh);392393 const text = decodeAztecCellRobust(394 cellCanvas,395 cellCtx,396 { x: 0, y: 0, w: sw, h: sh },397 renderScale,398 );399 if (!text) continue;400401 const envelope = parseChunkEnvelope(text);402 if (!envelope) continue;403 const stripeKey = `${envelope.s ?? 0}:${envelope.i}`;404 if (seenIndices.has(stripeKey)) continue;405 if (!(await verifyChunkIntegrity(envelope))) continue;406407 seenIndices.add(stripeKey);408 found.push(envelope);409 }410 }411412 return found;413}414415function decodeAztecCellRobust(416 canvas: HTMLCanvasElement,417 ctx: CanvasRenderingContext2D,418 cellRect: { x: number; y: number; w: number; h: number },419 renderScale: number,420): string | null {421 const strategies = getCellDecodeStrategies(renderScale);422 for (const strategy of strategies) {423 const result = decodeAztecCellWithStrategy(canvas, ctx, cellRect, strategy);424 if (result !== null) {425 return result;426 }427 }428 return null;429}430431function decodeAztecCellWithStrategy(432 canvas: HTMLCanvasElement,433 ctx: CanvasRenderingContext2D,434 cellRect: { x: number; y: number; w: number; h: number },435 strategy: CellDecodeStrategy,436): string | null {437 const { x, y, w, h } = applyCropFactor(cellRect, strategy.cropFactor, canvas.width, canvas.height);438 if (w <= 0 || h <= 0) return null;439440 const imageData = ctx.getImageData(x, y, w, h);441 const processedData = strategy.normalize442 ? normalizeToBlackWhite(imageData)443 : imageData;444445 const argb = imageDataToArgb(processedData);446 const { buffer, width, height } =447 strategy.upscale > 1448 ? upscaleArgb(argb, processedData.width, processedData.height, strategy.upscale)449 : { buffer: argb, width: processedData.width, height: processedData.height };450451 return decodeAztecFromArgb(buffer, width, height);452}453454function applyCropFactor(455 rect: { x: number; y: number; w: number; h: number },456 factor: number,457 canvasW: number,458 canvasH: number,459): { x: number; y: number; w: number; h: number } {460 const cx = rect.x + rect.w / 2;461 const cy = rect.y + rect.h / 2;462 const nw = Math.max(1, Math.round(rect.w * factor));463 const nh = Math.max(1, Math.round(rect.h * factor));464 const x = Math.max(0, Math.min(canvasW - 1, Math.round(cx - nw / 2)));465 const y = Math.max(0, Math.min(canvasH - 1, Math.round(cy - nh / 2)));466 const w = Math.max(1, Math.min(canvasW - x, nw));467 const h = Math.max(1, Math.min(canvasH - y, nh));468 return { x, y, w, h };469}470471function normalizeToBlackWhite(imageData: ImageData): ImageData {472 const data = new Uint8ClampedArray(imageData.data);473 for (let i = 0; i < data.length; i += 4) {474 const lum = 0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!;475 const v = lum > 128 ? 255 : 0;476 data[i] = v;477 data[i + 1] = v;478 data[i + 2] = v;479 data[i + 3] = 255;480 }481 return new ImageData(data, imageData.width, imageData.height);482}483484function upscaleArgb(485 argb: Int32Array,486 width: number,487 height: number,488 scale: number,489): { buffer: Int32Array; width: number; height: number } {490 const targetW = Math.max(1, Math.round(width * scale));491 const targetH = Math.max(1, Math.round(height * scale));492 const out = new Int32Array(targetW * targetH);493 for (let y = 0; y < targetH; y++) {494 const srcY = Math.min(height - 1, Math.floor(y / scale));495 for (let x = 0; x < targetW; x++) {496 const srcX = Math.min(width - 1, Math.floor(x / scale));497 out[y * targetW + x] = argb[srcY * width + srcX]!;498 }499 }500 return { buffer: out, width: targetW, height: targetH };501}502503504505506507508509510511512513export function cellRectFor(514 canvasW: number,515 canvasH: number,516 row: number,517 col: number,518): { sx: number; sy: number; sw: number; sh: number } {519 520 521 const scaleX = canvasW / PAGE_WIDTH;522 const scaleY = canvasH / PAGE_HEIGHT;523524 525 526 const gridTopPdf = PAGE_HEIGHT - MARGIN - HEADER_HEIGHT - HEADER_GAP;527 const gridTopCanvas = (PAGE_HEIGHT - gridTopPdf) * scaleY;528529 const codeWpx = CODE_SIZE * scaleX;530 const codeHpx = CODE_SIZE * scaleY;531 const quietX = Math.round(codeWpx * 0.12);532 const quietY = Math.round(codeHpx * 0.12);533534 const codeX = (MARGIN + col * (CODE_SIZE + COL_GAP)) * scaleX;535 const codeY = gridTopCanvas + row * ROW_STEP * scaleY;536537 const sx = Math.max(0, Math.floor(codeX - quietX));538 const sy = Math.max(0, Math.floor(codeY - quietY));539 const sw = Math.min(canvasW - sx, Math.ceil(codeWpx + 2 * quietX));540 const sh = Math.min(canvasH - sy, Math.ceil(codeHpx + 2 * quietY));541 return { sx, sy, sw, sh };542}543544545546547548549export function imageDataToArgb(imageData: ImageData): Int32Array {550 const raw = imageData.data;551 const out = new Int32Array(imageData.width * imageData.height);552 for (let i = 0, j = 0; i < raw.length; i += 4, j++) {553 out[j] = (0xff << 24) | (raw[i]! << 16) | (raw[i + 1]! << 8) | raw[i + 2]!;554 }555 return out;556}557558559560561562563564565566567568569570571572573574575export function decodeAztecFromArgb(576 argb: Int32Array,577 width: number,578 height: number,579): string | null {580 const source = new RGBLuminanceSource(argb, width, height);581582 try {583 const bitmap = new BinaryBitmap(new HybridBinarizer(source));584 return new AztecCodeReader().decode(bitmap).getText();585 } catch (err) {586 if (!(err instanceof NotFoundException)) return null;587 }588589 try {590 const bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));591 return new AztecCodeReader().decode(bitmap).getText();592 } catch {593 return null;594 }595}596597598599600601602603604605606607export function parseChunkEnvelope(raw: string): ChunkEnvelope | null {608 let parsed: unknown;609 try {610 parsed = JSON.parse(raw);611 } catch {612 return null;613 }614 if (typeof parsed !== "object" || parsed === null) return null;615 const obj = parsed as Record<string, unknown>;616617 if (obj.v !== 2 && obj.v !== 3) return null;618619 const baseShapeOk =620 typeof obj.c === "string" &&621 typeof obj.i === "number" &&622 typeof obj.t === "number" &&623 typeof obj.p === "boolean" &&624 typeof obj.d === "string" &&625 typeof obj.h === "string";626 if (!baseShapeOk) return null;627628 629 if (obj.v === 3 && (typeof obj.s !== "number" || typeof obj.S !== "number")) {630 return null;631 }632633 return obj as unknown as ChunkEnvelope;634}