A portfolio-grade pipeline that takes scanned invoice/receipt PDFs and turns
them into a clean, structured invoice_data.xlsx — header fields plus a
line-items table, linked by invoice number — with debug images showing exactly
what the OCR engine detected.
Portfolio demo. Showcases document data extraction (layout-aware OCR + field anchoring), as opposed to web scraping or structured-API extraction.
The brief allowed either. I picked docTR (PyTorch backend) over PaddleOCR for this environment:
- Installation reliability on Windows.
paddlepaddlehas a well-known history of DLL/Visual-C++-redistributable install issues on Windows. docTR's PyTorch backend uses the official PyTorch CPU wheels, which are some of the most thoroughly tested Windows wheels in the Python ecosystem. - Built for documents, not scene text. docTR ("document text recognition") targets printed documents specifically — its accuracy on clean invoice-style text is strong out of the box with the default pretrained detector+recognizer.
- Output shape fits this task. docTR returns a
Document → Page → Block → Line → Wordhierarchy with a normalized bounding box per word. That maps directly onto "locate fields by layout position," which is the core requirement here — no extra geometry post-processing needed.
samples/*.pdf
│ pdf2image (poppler)
▼
page image (PIL)
│ docTR ocr_predictor (pretrained)
▼
list[Word] (text, pixel bbox, confidence) <- ocr_engine.py (I/O layer)
│
▼
group_into_lines → extract_header / extract_line_items <- extract.py (PURE)
│
├──► invoice_data.xlsx (Headers + LineItems sheets) <- excel_writer.py
├──► debug_images/*.png (boxes over detected words) <- debug_image.py
└──► console summary (+ low-confidence / blank-field notes)
Why this split matters: every function in extract.py takes plain data in
(list[Word], a page height) and returns plain data out (a dict of fields, a
list of item dicts). No file I/O, no OCR engine, no network calls. That's what
makes tests/test_extract.py able to fully exercise the field-location logic
offline, with hand-built word lists, in milliseconds — no model load required.
- Header fields (
invoice_number,date,currency,subtotal,tax,total): found via case-insensitive keyword anchors ("Invoice Number:", "Date:", "Subtotal:", ...), then the value is read from the rest of that line — even when several labels share one line (e.g. an inlineInvoice #: X Date: Y Currency: Zstrip), the parser stops a value at the next recognized label instead of swallowing the row. vendor_namehas no reliable label on real invoices, so it falls back to positional logic: the first non-title line within the top 25% of the page.- Line items: the table's column-header row is detected by scoring each
line for how many distinct column keywords it contains (
description,qty/quantity,price,amount/total) — whichever row scores highest (≥3 distinct columns) is the header. Column boundaries are then the midpoints between header-label x-positions, and every word below that row (until a "Subtotal" line) is bucketed into the nearest column by x-position. - Nothing is hardcoded to one layout. The 3 sample invoices deliberately
use different vendor-block positions, column orders, label wording, date
formats (ISO /
MM/DD/YYYY/ "12 March 2024") and currencies (USD/EUR/GBP) to prove this. - Ambiguous → blank, not guessed. If no anchor/column match is found, the
field is left as
Nonerather than filled with a best-effort guess. - Low OCR confidence is flagged, not hidden: any header field whose contributing word(s) average below 0.5 confidence is listed in the console summary under "Low OCR confidence".
python -m venv .venv
.venv\Scripts\activate # Windows
pip install -r requirements.txtpdf2image needs the poppler binaries on PATH (Windows: winget install oschwartz10612.Poppler, then restart your shell — or set the POPPLER_PATH
env var to poppler's bin folder without restarting).
python generate_samples.py # writes 3 sample invoices to samples/
python main.py # OCR + extract + Excel + debug images + summaryThe first python main.py run downloads docTR's pretrained weights
(one-time, ~100 MB, requires network access).
Run the offline test (no OCR, no network):
python tests/test_extract.py--- invoice_layout_a.pdf ---
Vendor: Northwind Office Supplies LLC
Invoice Number: INV-2024-0157
Date: 2024-03-12
Currency: USD
Subtotal: 834.73
Tax: 68.87
Total: 903.6
Line items: 5
--- invoice_layout_b.pdf ---
Vendor: Lumiere Design Studio
Invoice Number: LDS-8841
Date: 2024-03-12
Currency: EUR
Subtotal: 2250.0
Tax: 450.0
Total: 2700.0
Line items: 4
--- invoice_layout_c.pdf ---
Vendor: Harborline Logistics Ltd.
Invoice Number: RC-55291
Date: 2024-03-12
Currency: GBP
Subtotal: 525.0
Tax: 42.0
Total: 567.0
Line items: 5
All three header sets and every line item (15 total) were verified by hand against the generated sample data — full match, despite three different vendor-block positions, column orders, label phrasings, date formats and currencies.
output/invoice_data.xlsx— Headers sheet (one row per invoice: source_file, invoice_number, vendor_name, date, currency, subtotal, tax, total) + LineItems sheet (one row per item: invoice_number, description, quantity, unit_price, line_total), linked byinvoice_number.debug_images/*_debug.png— each page image with a box drawn around every OCR'd word (green = confident, red = low confidence).- Console: a per-invoice summary plus explicit notes for any blank or low-confidence field.
- Single-page invoices only (only the first PDF page is processed).
- Line items must fit on one visual row each — multi-line wrapped descriptions aren't merged back into a single item.
- The line-item table needs at least 3 of the 4 column keywords (description/qty/price/amount) to be detected at all; a table that uses none of those words won't be found.
- Date parsing covers ISO,
MM/DD/YYYY, and "D Month YYYY" formats — other formats are left blank rather than mis-parsed. - Tuned and verified against generated, born-digital sample PDFs. Real scanned/photographed invoices (skew, noise, creases) will stress the OCR engine itself harder than these samples do — the extraction logic's blank-on-ambiguous and confidence-flagging behavior is the safety net for that, by design.
Python · docTR (PyTorch) · pdf2image/poppler · Pillow · pandas · openpyxl · reportlab (sample generation)
Need structured data out of invoices, receipts, or forms that only exist as scanned PDFs or photos? I build extraction pipelines like this one for real document sets.