import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { execFile } from "node:child_process"; import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import type { OcrPage, OcrProvider, OcrWord } from "./ocr.provider"; const run = promisify(execFile); /** * Self-hosted OCR: `pdftoppm` (poppler) to rasterise, `tesseract` to read. * * Both are external binaries rather than a native npm addon, which keeps the * pnpm workspace free of a compiled dependency and makes the alpine runtime * image a two-package change (see docker/api.Dockerfile). Like StorageService, * a missing binary degrades rather than crashes the API: the module reports * itself unavailable and statement ingest returns 503, while every other * feature keeps working. * * The settings below are not arbitrary — they were measured against the real * scanned samples: * - 300 DPI grayscale. The source scans are phone photos of paper at ~5MB a * page; below 300 the small print (RMU, clave catastral) stops resolving, * above it costs time for no additional fields. * - `--psm 6` ("assume a single uniform block of text"). The default page * segmentation splits these dense forms into columns and interleaves them, * which destroys the label-then-value adjacency every parser depends on. * - Spanish traineddata, with a graceful fall back to English if the language * pack is absent — an accented label reads worse but the digits, which are * what actually gets matched, are unaffected. */ @Injectable() export class TesseractOcrProvider implements OcrProvider { private readonly logger = new Logger(TesseractOcrProvider.name); private readonly dpi: number; private readonly lang: string; private probe: Promise | null = null; constructor(config: ConfigService) { this.dpi = Number(config.get("OCR_DPI") ?? 300); this.lang = config.get("OCR_LANG") ?? "spa"; } /** Cached — the binaries do not appear or vanish while the process runs. */ available(): Promise { if (!this.probe) { this.probe = (async () => { try { await Promise.all([ run("tesseract", ["--version"]), run("pdftoppm", ["-v"]), ]); return true; } catch { this.logger.warn( "OCR unavailable: `tesseract` and/or `pdftoppm` not found on PATH. " + "Statement ingest is disabled; every other feature is unaffected.", ); return false; } })(); } return this.probe; } private async require(): Promise { if (!(await this.available())) { throw new ServiceUnavailableException( "El servicio de OCR no está disponible en este servidor.", ); } } private async scratch(fn: (dir: string) => Promise): Promise { const dir = await mkdtemp(join(tmpdir(), "stmt-ocr-")); try { return await fn(dir); } finally { await rm(dir, { recursive: true, force: true }); } } async renderPages(pdf: Buffer): Promise { await this.require(); return this.scratch(async (dir) => { const src = join(dir, "in.pdf"); await writeFile(src, pdf); // -gray: these are grayscale scans already; colour triples the bytes // handed to tesseract for no gain in character recognition. await run("pdftoppm", [ "-r", String(this.dpi), "-gray", "-png", src, join(dir, "page"), ]); const files = (await readdir(dir)) .filter((f) => f.startsWith("page") && f.endsWith(".png")) // pdftoppm zero-pads its page numbers, so lexical order is page order. .sort(); return Promise.all(files.map((f) => readFile(join(dir, f)))); }); } /** * `pdftotext -bbox-layout` — the same poppler package `pdftoppm` comes from, * so this costs no extra dependency in the runtime image. * * A page is only accepted when it carries a real text layer. Scanned PDFs * frequently contain a handful of stray glyphs (a scanner watermark, a page * number stamped by the MFP), and treating those as the page's text would * hand every parser an almost-empty string and silently take OCR out of the * loop — so a floor of MIN_TEXT_WORDS words has to be present before the * layer is believed. */ async textPages(pdf: Buffer): Promise<(OcrPage | null)[]> { await this.require(); return this.scratch(async (dir) => { const src = join(dir, "in.pdf"); await writeFile(src, pdf); const out = join(dir, "out.html"); try { await run("pdftotext", ["-bbox-layout", src, out]); } catch (err) { this.logger.warn( `pdftotext failed; falling back to OCR for this file: ${(err as Error).message}`, ); return []; } // Points to pixels at the render DPI, so word boxes from either source // land in one coordinate space and `valueUnder`'s thresholds hold. return parseBboxLayout(await readFile(out, "utf8"), this.dpi / 72); }); } async recognize(pageImage: Buffer): Promise { await this.require(); return this.scratch(async (dir) => { const img = join(dir, "page.png"); await writeFile(img, pageImage); // One tesseract invocation produces both outputs; TSV carries the word // boxes and per-word confidence, and its text can be reassembled into // reading order, so there is no need to run the engine twice. const out = join(dir, "out"); try { await run("tesseract", [img, out, "-l", this.lang, "--psm", "6", "tsv"]); } catch (err) { if (this.lang !== "eng") { this.logger.warn( `Tesseract failed with lang "${this.lang}", retrying with "eng": ${ (err as Error).message }`, ); await run("tesseract", [img, out, "-l", "eng", "--psm", "6", "tsv"]); } else { throw err; } } const tsv = await readFile(`${out}.tsv`, "utf8"); return parseTsv(tsv); }); } } /** * Below this many words a "text layer" is scanner debris, not a document. * The real born-digital samples carry 400+ words a page; the scanned ones * carry none at all, so the exact threshold is not delicate. */ const MIN_TEXT_WORDS = 40; const ENTITIES: Record = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", }; function decodeEntities(s: string): string { return s.replace(/&(#x?[0-9a-fA-F]+|[a-z]+);/g, (whole, body: string) => { if (body[0] === "#") { const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10); return Number.isFinite(code) ? String.fromCodePoint(code) : whole; } return ENTITIES[body] ?? whole; }); } /** * Turn `pdftotext -bbox-layout`'s XHTML into one OcrPage per PDF page. * * Parsed with regexes rather than an XML library on purpose: the output is * machine-generated by poppler with a fixed element shape (`page` > `flow` > * `block` > `line` > `word`), and the alternative is a parser dependency in * the API for one file format read in one place. Only `page` and `word` are * consulted — see below for why poppler's own `line` grouping is discarded. * * `confidence` is 1 for every word: these are the document's own characters, * not a recognition guess. */ export function parseBboxLayout(xhtml: string, scale: number): (OcrPage | null)[] { const pages: (OcrPage | null)[] = []; for (const pageMatch of xhtml.matchAll(/]*>([\s\S]*?)<\/page>/g)) { const words: OcrWord[] = []; for (const w of pageMatch[1].matchAll( /([\s\S]*?)<\/word>/g, )) { const text = decodeEntities(w[5]).trim(); if (!text) continue; const left = Number(w[1]) * scale; const top = Number(w[2]) * scale; words.push({ text, left, top, width: Number(w[3]) * scale - left, height: Number(w[4]) * scale - top, confidence: 1, }); } pages.push( words.length >= MIN_TEXT_WORDS ? { text: toVisualRows(words), words, confidence: 1 } : null, ); } return pages; } /** * Reassemble words into the rows a reader sees, left to right. * * Poppler's own `` grouping cannot be used for this. It groups by text * flow, and these invoices lay their fields out as two columns of independent * flows — so `PERIODO FACTURADO:` and the `20260630-20260630` printed beside * it end up in different `` elements, and every label-then-value pattern * in the parsers misses a value that is plainly there on the page. Regrouping * by vertical position restores the adjacency, and matches what tesseract * hands back for the scanned version of the same layout. * * Rows are cut when a word's vertical centre leaves the band established by * the row's first word, which tolerates the sub-pixel baseline differences * between fonts on one line without merging two genuinely separate lines. */ function toVisualRows(words: OcrWord[]): string { const centre = (w: OcrWord) => w.top + w.height / 2; const sorted = [...words].sort((a, b) => centre(a) - centre(b) || a.left - b.left); const rows: OcrWord[][] = []; let current: OcrWord[] = []; let band = 0; for (const w of sorted) { if (!current.length) { current = [w]; band = centre(w); continue; } // Half the word's own height: tall headings and body text both sit within // their own line's band, and neither reaches into the next one. if (Math.abs(centre(w) - band) <= Math.max(w.height, current[0].height) / 2) { current.push(w); } else { rows.push(current); current = [w]; band = centre(w); } } if (current.length) rows.push(current); return rows .map((r) => [...r] .sort((a, b) => a.left - b.left) .map((w) => w.text) .join(" "), ) .join("\n"); } /** * Turn tesseract's TSV into words plus reassembled text. * * Columns are: level, page_num, block_num, par_num, line_num, word_num, left, * top, width, height, conf, text. Rows with level < 5 are structural (page, * block, paragraph, line) and carry no text; only level 5 is a word. A conf of * -1 marks a structural row, so those are dropped rather than averaged in — * including them would drag every page's confidence toward zero. */ export function parseTsv(tsv: string): OcrPage { const lines = tsv.split("\n"); const header = lines[0]?.split("\t") ?? []; const col = (name: string) => header.indexOf(name); const iLeft = col("left"); const iTop = col("top"); const iWidth = col("width"); const iHeight = col("height"); const iConf = col("conf"); const iText = col("text"); const iLine = col("line_num"); const iBlock = col("block_num"); const words: OcrWord[] = []; // Keyed by block+line so the reassembled text preserves the engine's own // reading order instead of sorting words by raw y, which interleaves columns. const byLine = new Map(); for (let i = 1; i < lines.length; i++) { const f = lines[i].split("\t"); if (f.length <= iText) continue; const text = f[iText]?.trim(); if (!text) continue; const confidence = Number(f[iConf]); if (!Number.isFinite(confidence) || confidence < 0) continue; words.push({ text, left: Number(f[iLeft]) || 0, top: Number(f[iTop]) || 0, width: Number(f[iWidth]) || 0, height: Number(f[iHeight]) || 0, confidence: confidence / 100, }); const key = `${f[iBlock]}:${f[iLine]}`; const bucket = byLine.get(key); if (bucket) bucket.push(text); else byLine.set(key, [text]); } const text = [...byLine.values()].map((w) => w.join(" ")).join("\n"); const confidence = words.length ? words.reduce((sum, w) => sum + w.confidence, 0) / words.length : 0; return { text, words, confidence }; }