Getting Numbers Out of PDFs and Invoices

Getting Numbers Out of PDFs and Invoices

Before writing any code, open the PDF and try to select a line of text with the mouse. If it highlights word by word, there is real text inside and the job is mostly parsing. If the whole page lights up as one rectangle, or nothing does, you are holding a picture of a page, and the job has just become OCR.

Both kinds land in the same folder of supplier invoices, and both must produce the same fields: number, date, taxable value, tax, total. Getting the text out is the easy half. Knowing whether the numbers are right is the half that matters.

Text PDFs and scans

A PDF exported from an invoicing or accounting package carries its text, character by character, so reading it is exact. A scan carries only pixels. Some scanners add a hidden text layer of their own, which helps when it is good and misleads when it is not, because your code cannot tell it from the real thing.

So test each file rather than trusting where it came from. If a page yields no text, or a few stray characters, treat it as a scan and send it through OCR first (Tesseract is the long-standing open-source engine). Everything after OCR deserves more suspicion: 0 and O, 1 and l, a comma read as a full stop.

Getting the text out

For text PDFs in Python, pdfplumber is a sound choice. extract_text() gives each page as lines, extract_tables() tries to rebuild tables from their ruling lines, and extract_words() returns every word with its coordinates, for when a value is known by where it sits rather than what it looks like.

One trap: text comes out in the order it was drawn, not always the order a person reads it. Two-column headers and boxed addresses arrive interleaved. Read the extracted text of a few real invoices before writing a single pattern.

Finding the fields

Most invoice fields sit beside a label, so the dependable pattern is label first, value second: find the line that starts with Total and take the first amount on it. Anchoring to the start of the line matters, or Total also matches Sub Total.

Write the amount pattern for the numbers you will actually meet. An Indian invoice writes one lakh forty-one thousand six hundred as 1,41,600.00. A pattern that expects a comma every three digits does not just miss it; it finds 41,600.00 inside it and reports that, which is worse. Accept digits and commas, require the decimal part, strip the commas, and convert to Decimal, never float, because this is money.

Suppliers lay out invoices differently, so keep a small set of patterns per layout rather than one heroic pattern for all. A language model can pull the fields out too, and on messy layouts it often does well. That makes the next step more important, not less: a model that misreads a figure does it fluently.

Add it up before you believe it

Auditors call it casting: adding a document up yourself before relying on its total, and my years in audit left me with the habit for good. Invoices make it easy, because they carry their own cross-checks. Taxable value plus tax should equal the total, give or take the round-off to the nearest rupee. The tax should be the stated rate applied to the taxable value. The date should parse and should not be in the future. And the invoice number should not already be in the books.

Here is a small version for an invoice whose last three lines carry the amounts. Real layouts need their own labels, but the checks carry over.

import re
from datetime import date, datetime
from decimal import Decimal

import pdfplumber

def pdf_text(path):
    with pdfplumber.open(path) as pdf:
        return "\n".join(page.extract_text() or "" for page in pdf.pages)

def amount(label, text):
    # The label at the start of a line, then the first amount on that line.
    # [\d,]+ reads 1,41,600.00 as readily as 141,600.00
    m = re.search(r"^\s*" + label + r"\b.*?([\d,]+\.\d{2})", text, re.I | re.M)
    return Decimal(m[1].replace(",", "")) if m else None

def check(text):
    taxable = amount("Taxable Value", text)
    tax = amount("GST", text)
    total = amount("Total", text)
    if None in (taxable, tax, total):
        return ["a field was not found"]

    problems = []
    if abs(taxable + tax - total) > 1:  # allow the rupee round-off
        problems.append(f"{taxable} + {tax} does not make {total}")
    rate = re.search(r"GST\s*@\s*(\d+(?:\.\d+)?)\s*%", text)
    if rate and abs(taxable * Decimal(rate[1]) / 100 - tax) > Decimal("0.50"):
        problems.append(f"{rate[1]}% of {taxable} is not {tax}")
    found = re.search(r"Invoice Date\s*:?\s*(\d{2}/\d{2}/\d{4})", text)
    try:
        when = datetime.strptime(found[1], "%d/%m/%Y").date()
        if when > date.today():
            problems.append(f"dated in the future: {when}")
    except (TypeError, ValueError):  # no date at all, or 31/02/2025
        problems.append("no readable invoice date")
    return problems

sample = """Invoice No: INV/24-25/0417
Invoice Date: 14/02/2025
Taxable Value         1,20,000.00
GST @ 18%               21,600.00
Total                 1,41,600.00"""

print(check(sample) or "all checks passed")

Change 21,600.00 to 21,060.00 and you get two reasons in plain words, not a stack trace, which matters for the next step.

When a person should look

Extraction does not have to be perfect. It has to know when it is unsure. Send an invoice to a person when:

  • a check fails, or a field is missing;
  • it came through OCR, at least until you know how OCR copes with that supplier’s invoices;
  • the layout is new, since that is when your patterns are most likely to be wrong;
  • the amount is large enough that a mistake costs more than a minute of someone’s time.

Show the reviewer the PDF beside the extracted fields and the failed check, and record what they correct: those corrections show which patterns to fix. And never nudge an extracted figure so that a check passes. That turns the check into decoration.

A claim, then evidence

None of this takes people out of the process. It makes sure that when someone opens an invoice, that invoice deserves it. A number pulled from a PDF is a claim. A number that adds up, matches its own tax and carries a date that makes sense is evidence, and evidence is what goes into the books.