Skip to content

Repository files navigation

Invoice / Receipt Extractor — Scanned Document OCR → Structured Excel

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.

Why docTR over PaddleOCR

The brief allowed either. I picked docTR (PyTorch backend) over PaddleOCR for this environment:

  • Installation reliability on Windows. paddlepaddle has 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 → Word hierarchy 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.

Pipeline

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.

How fields are located

  • 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 inline Invoice #: X Date: Y Currency: Z strip), the parser stops a value at the next recognized label instead of swallowing the row.
  • vendor_name has 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 None rather 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".

Install & run

python -m venv .venv
.venv\Scripts\activate          # Windows
pip install -r requirements.txt

pdf2image 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 + summary

The 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

Sample output

--- 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

  • output/invoice_data.xlsxHeaders 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 by invoice_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.

Known limitations

  • 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.

Tech stack

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.

About

Scanned invoice/receipt PDF -> structured Excel via OCR (docTR)

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages