Problem
Invoices arrive as PDFs in at least two structurally different layouts — tables and free-form paragraphs — with numbers formatted for different regions. Turning that into something a downstream system can trust means more than “extract the text”: it means normalizing, validating, and reconciling values that were never guaranteed to be consistent in the first place.
Constraints
This was built as a self-contained CLI tool with explicit scope boundaries set up front: no OCR (inputs are text-layer PDFs, not scans), no LLM dependency, no database, no web UI. The constraint was deliberate — a tool that does one thing reliably, with no network calls and no model non-determinism to account for, is easier to trust and easier to test exhaustively.
Approach & architecture
flowchart LR
A[Invoice PDF] --> B[pdfplumber extraction<br/>table + paragraph layouts]
B --> C[Pydantic typed records]
C --> D[Validation & reconciliation]
D --> E[JSON / CSV / XLSX export]
Extraction is layout-aware: table-structured invoices and paragraph-structured invoices go through different parsing paths before converging on the same canonical Pydantic schema. Values are validated against structural expectations and reconciled against each other (line items against totals, for example) rather than trusted individually. The regression suite is built around ground truth across layouts and regional number formats specifically because that’s where a naive parser quietly breaks — a “1.234,56” and a “1,234.56” invoice should never produce different confidence in the output.
What I’d change now
The layout coverage is real but not exhaustive — a genuinely novel invoice layout still needs a new extraction path written by hand. A configurable, rule-based layout descriptor (rather than a new code path per layout family) would let the tool cover new formats without a code change, while keeping the no-LLM constraint intact.
Impact
The tool ships with 200+ automated tests covering both layout families and multiple regional number formats, with zero external service calls — no API keys, no network dependency, no OCR pipeline to keep patched. In a local demo run it processes a representative invoice in about 43 seconds end to end, cited here as a runtime observation from that setup, not a universal benchmark.