/** * Browser-side Tesseract OCR for Layer-1 card name strip recognition. * Uses tesseract.js workers (off main thread). Language data loads from CDN on first use. */ let workerPromise = null; const NAME_STRIP_PSM = '7'; const NUMBER_STRIP_PSM = '7'; const NAME_STRIP_HEIGHT_RATIO = 0.35; const NUMBER_STRIP_HEIGHT_RATIO = 0.18; const OCR_JPEG_QUALITY = 0.92; async function getWorker() { if (!workerPromise) { workerPromise = (async () => { const { createWorker } = await import('tesseract.js'); const worker = await createWorker('eng', 1, { logger: () => {}, }); return worker; })(); } return workerPromise; } function cropCardStrip(imageDataUrl, { fromTop = true, heightRatio }) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { const stripHeight = Math.max(1, Math.floor(img.height * heightRatio)); const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = stripHeight; const ctx = canvas.getContext('2d'); const sourceY = fromTop ? 0 : Math.max(0, img.height - stripHeight); ctx.drawImage( img, 0, sourceY, img.width, stripHeight, 0, 0, canvas.width, canvas.height ); resolve(canvas.toDataURL('image/jpeg', OCR_JPEG_QUALITY)); }; img.onerror = () => reject(new Error('Failed to load image for OCR')); img.src = imageDataUrl; }); } /** * Pick the most likely collector number token from OCR of the bottom strip. * @param {string} rawText * @returns {string} */ export function extractCollectorNumberCandidate(rawText) { if (!rawText || typeof rawText !== 'string') return ''; const lines = rawText .split(/\r?\n/) .map((line) => line.replace(/\s+/g, ' ').trim()) .filter(Boolean); const joined = lines.join(' '); const slashMatch = joined.match(/\b(\d{1,4}\s*\/\s*\d{1,4}[A-Za-z]?)\b/); if (slashMatch) { return slashMatch[1].replace(/\s+/g, ''); } for (let i = lines.length - 1; i >= 0; i -= 1) { const line = lines[i]; if (/\d/.test(line) && line.length <= 12) { return line; } } return ''; } /** * OCR the top name strip of a card crop. * @param {string} imageDataUrl - full card crop data URL * @returns {Promise<{ text: string, confidence: number }>} */ export async function recognizeCardNameStrip(imageDataUrl) { const fields = await recognizeCardFields(imageDataUrl); return { text: fields.nameText, confidence: fields.nameConfidence, }; } /** * OCR name strip + bottom collector-number strip. * @param {string} imageDataUrl * @returns {Promise<{ nameText: string, nameConfidence: number, cardNumber: string, numberConfidence: number }>} */ export async function recognizeCardFields(imageDataUrl) { if (typeof window === 'undefined') { return { nameText: '', nameConfidence: 0, cardNumber: '', numberConfidence: 0 }; } try { const [nameStripUrl, numberStripUrl] = await Promise.all([ cropCardStrip(imageDataUrl, { fromTop: true, heightRatio: NAME_STRIP_HEIGHT_RATIO }), cropCardStrip(imageDataUrl, { fromTop: false, heightRatio: NUMBER_STRIP_HEIGHT_RATIO }), ]); const worker = await getWorker(); await worker.setParameters({ tessedit_pageseg_mode: NAME_STRIP_PSM }); const nameResult = await worker.recognize(nameStripUrl); await worker.setParameters({ tessedit_pageseg_mode: NUMBER_STRIP_PSM }); const numberResult = await worker.recognize(numberStripUrl); const cardNumber = extractCollectorNumberCandidate(numberResult.data.text || ''); return { nameText: (nameResult.data.text || '').trim(), nameConfidence: Math.round(nameResult.data.confidence || 0), cardNumber, numberConfidence: Math.round(numberResult.data.confidence || 0), }; } catch (error) { console.warn('[ocr-worker] recognizeCardFields failed:', error); return { nameText: '', nameConfidence: 0, cardNumber: '', numberConfidence: 0 }; } } export async function terminateOcrWorker() { if (!workerPromise) return; try { const worker = await workerPromise; await worker.terminate(); } catch { // ignore teardown errors } workerPromise = null; }