/** * Tier 2 helper — rasterize PDF pages to PNG buffers so scanned/image-only * invoices can be OCR'd (tesseract.js works on images, not PDF text streams). * * Primary: `pdftoppm` (Poppler) — fast & robust; present on the dev box and the * documented prod install (mirrors the whisper-cpp binary convention). * Fallback: pure-Node pdfjs-dist + node-canvas, so the feature degrades * gracefully if Poppler is missing. Both paths are fail-soft → return [] and the * pipeline yields a low-confidence/empty result rather than throwing. */ import { execFile } from 'child_process' import { promisify } from 'util' import { writeFileSync, readFileSync, readdirSync, unlinkSync, existsSync, mkdirSync, rmSync } from 'fs' import { join } from 'path' const execFileAsync = promisify(execFile) const DEFAULT_DPI = 300 const MAX_PAGES = 8 // bound OCR time on very long PDFs; we log when we truncate const PDFTOPPM_CANDIDATES = [ process.env.PDFTOPPM_PATH, '/opt/homebrew/bin/pdftoppm', '/usr/local/bin/pdftoppm', '/usr/bin/pdftoppm', 'pdftoppm' ].filter(Boolean) as string[] const resolvePdftoppm = async (): Promise => { for (const cand of PDFTOPPM_CANDIDATES) { try { await execFileAsync(cand, ['-v']) return cand } catch (e: any) { // pdftoppm -v exits non-zero but prints to stderr when it exists; treat // "command not found"/ENOENT as absent, anything else as present. if (e?.code === 'ENOENT') continue return cand } } return null } const rasterizeWithPdftoppm = async (bin: string, pdfBuffer: Buffer, dpi: number, maxPages: number): Promise => { const tmpDir = join(process.cwd(), '.tmp-ap-ocr') if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) const stamp = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) const inPath = join(tmpDir, `${stamp}.pdf`) const outPrefix = join(tmpDir, `${stamp}-page`) writeFileSync(inPath, pdfBuffer) try { await execFileAsync(bin, ['-png', '-r', String(dpi), '-l', String(maxPages), inPath, outPrefix], { maxBuffer: 1024 * 1024 * 64 }) const produced = readdirSync(tmpDir) .filter(f => f.startsWith(`${stamp}-page`) && f.endsWith('.png')) .sort() const buffers = produced.map(f => readFileSync(join(tmpDir, f))) // cleanup try { unlinkSync(inPath) } catch {} for (const f of produced) { try { unlinkSync(join(tmpDir, f)) } catch {} } return buffers } catch (err) { try { unlinkSync(inPath) } catch {} throw err } } const rasterizeWithCanvas = async (pdfBuffer: Buffer, dpi: number, maxPages: number): Promise => { const pdfjs: any = await import('pdfjs-dist/legacy/build/pdf.mjs') const { createCanvas } = await import('canvas') const pdf = await pdfjs.getDocument({ data: new Uint8Array(pdfBuffer), isEvalSupported: false, verbosity: 0 }).promise const scale = dpi / 72 const out: Buffer[] = [] const pageCount = Math.min(pdf.numPages, maxPages) for (let p = 1; p <= pageCount; p++) { const page = await pdf.getPage(p) const viewport = page.getViewport({ scale }) const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height)) const ctx = canvas.getContext('2d') await page.render({ canvasContext: ctx as any, viewport }).promise out.push(canvas.toBuffer('image/png')) } try { await pdf.cleanup() } catch {} return out } export interface RasterizeResult { pages: Buffer[] truncated: boolean engine: 'pdftoppm' | 'canvas' | 'none' } export const rasterizePdf = async (pdfBuffer: Buffer, opts: { dpi?: number; maxPages?: number } = {}): Promise => { const dpi = opts.dpi ?? DEFAULT_DPI const maxPages = opts.maxPages ?? MAX_PAGES let pages: Buffer[] = [] let engine: RasterizeResult['engine'] = 'none' const bin = await resolvePdftoppm() if (bin) { try { pages = await rasterizeWithPdftoppm(bin, pdfBuffer, dpi, maxPages) engine = 'pdftoppm' } catch (e: any) { console.warn('[Inbox] pdftoppm rasterize failed, trying canvas fallback:', e?.message || e) } } if (pages.length === 0) { try { pages = await rasterizeWithCanvas(pdfBuffer, dpi, maxPages) engine = 'canvas' } catch (e: any) { console.warn('[Inbox] canvas rasterize fallback failed:', e?.message || e) engine = 'none' } } // We can't know the true page count cheaply without parsing; flag truncation // when we hit the cap so the caller can log "only first N pages OCR'd". const truncated = pages.length >= MAX_PAGES if (truncated) console.warn(`[Inbox] rasterize capped at ${MAX_PAGES} pages`) return { pages, truncated, engine } } /** * Render a small page-1 thumbnail as a JPEG data URL (for the inbox cards). * Strapi does NOT generate thumbnails for PDFs, so we make our own. Fail-soft → ''. */ export const renderThumbDataUrl = async (pdfBuffer: Buffer, width = 220): Promise => { try { const r = await rasterizePdf(pdfBuffer, { dpi: 110, maxPages: 1 }) if (!r.pages[0]) return '' const sharp = (await import('sharp')).default const buf = await sharp(r.pages[0]).resize({ width, withoutEnlargement: true }).jpeg({ quality: 62 }).toBuffer() return 'data:image/jpeg;base64,' + buf.toString('base64') } catch (e: any) { console.warn('[Inbox] thumbnail render failed:', e?.message || e) return '' } } /** Best-effort temp-dir sweep (call occasionally; safe to ignore failures). */ export const cleanupRasterTmp = () => { const tmpDir = join(process.cwd(), '.tmp-ap-ocr') try { if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true }) } catch {} } export default rasterizePdf