/** * Tier 1 — text-layer extraction for digital (non-scanned) PDFs. * * Uses pdfjs-dist (legacy build runs in Node) to pull text items WITH their * positions. Coordinates are kept (PDF points, bottom-left origin, + page size) * because the per-vendor learning layer remembers where each field sits. * * Fail-soft: returns { hasText:false } on any error so the pipeline rasterizes * and OCRs instead (Tier 2). */ export interface TextItem { text: string x: number y: number w: number h: number /** 1-based page + page-normalized (0..1, top-left origin) box for UI overlays. */ page?: number nx?: number ny?: number nw?: number nh?: number } export interface TextPage { pageNum: number width: number height: number items: TextItem[] } export interface TextLayerResult { hasText: boolean text: string pages: TextPage[] } const EMPTY: TextLayerResult = { hasText: false, text: '', pages: [] } export const extractTextLayer = async (pdfBuffer: Buffer): Promise => { let pdfjs: any try { // Legacy build is the Node-compatible entry point. pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs') } catch (e: any) { // If this fires in prod, pdfjs-dist isn't resolvable from .output → every PDF // (even digital ones) falls through to OCR. Promote it to a direct dependency. console.warn('[Inbox] Tier 1 disabled — pdfjs-dist failed to load:', e?.message || e) return EMPTY } try { const data = new Uint8Array(pdfBuffer) const loadingTask = pdfjs.getDocument({ data, isEvalSupported: false, useSystemFonts: true, disableFontFace: true, verbosity: 0 }) const pdf = await loadingTask.promise const pages: TextPage[] = [] const textChunks: string[] = [] for (let p = 1; p <= pdf.numPages; p++) { const page = await pdf.getPage(p) const viewport = page.getViewport({ scale: 1 }) const vw = viewport.width || 1 const vh = viewport.height || 1 const content = await page.getTextContent() const items: TextItem[] = [] const lineParts: string[] = [] for (const it of content.items as any[]) { const str = it.str ?? '' if (!str) continue const tr = it.transform || [1, 0, 0, 1, 0, 0] const w = it.width || 0 const h = it.height || Math.hypot(tr[1], tr[3]) const x = tr[4] const y = tr[5] // PDF baseline, bottom-left origin items.push({ text: str, x, y, w, h, // page-normalized, top-left origin (for UI overlay positioning) page: p, nx: Math.max(0, Math.min(1, x / vw)), ny: Math.max(0, Math.min(1, (vh - y - h) / vh)), nw: Math.max(0, Math.min(1, w / vw)), nh: Math.max(0, Math.min(1, h / vh)) }) lineParts.push(str) if (it.hasEOL) lineParts.push('\n') } pages.push({ pageNum: p, width: viewport.width, height: viewport.height, items }) textChunks.push(lineParts.join(' ').replace(/[ \t]*\n[ \t]*/g, '\n')) } try { await pdf.cleanup() } catch {} const text = textChunks.join('\n\n').trim() // "Has text" only if there's a meaningful amount — a couple stray glyphs on a // scan shouldn't pre-empt OCR. const hasText = text.replace(/\s/g, '').length >= 30 return { hasText, text, pages } } catch (e: any) { console.warn('[Inbox] Tier 1 text extraction failed (falling back to OCR):', e?.message || e) return EMPTY } } export default extractTextLayer