DEV Community

rene
rene

Posted on

The table you extracted from a PDF has silently wrong numbers. Here are 4 checks that catch it in Python

You pull a table out of a PDF, load it into your script, and the total is off by 1,200. No error, no warning. The extractor did exactly what it was told. The numbers are just wrong.

Here is a six-row example of what extraction can hand you:

rows = [
    ["Item", "Qty", "Amount"],
    ["Hosting", "1", "1,200.00"],
    ["Support", "2", "(300.00)"],      # negative in parentheses
    ["Domain", "1", "15.00"],
    ["Item", "Qty", "Amount"],          # header repeated: a page break leaked in
    ["Backup", "1", "80.00", "x"],      # ragged row: shifted column
    ["Total", "", "995.00"],
]
Enter fullscreen mode Exit fullscreen mode

Libraries such as pdfplumber give you rows like these. They do not tell you whether the rows are trustworthy. Four cheap checks do.

Check 1: every row has the same number of columns

def check_ragged(rows):
    widths = {len(r) for r in rows}
    return len(widths) == 1, widths
Enter fullscreen mode Exit fullscreen mode

A shifted or merged cell changes the width of one row. On the example above this returns (False, {3, 4}).

Check 2: every amount parses, or you fail loudly

Real PDFs write numbers as 1,200.00, (300.00) or -300.00. Use Decimal, never float, so cents stay exact (see the decimal docs).

from decimal import Decimal, InvalidOperation

def parse_amount(text):
    s = text.strip().replace(",", "").replace("$", "")
    neg = s.startswith("(") and s.endswith(")")
    s = s.strip("()")
    try:
        value = Decimal(s)
    except InvalidOperation:
        raise ValueError(f"not a number: {text!r}")
    return -value if neg else value
Enter fullscreen mode Exit fullscreen mode

If a minus sign was lost or a stray character slipped in, you get an exception instead of a quiet wrong total.

Check 3: line items add up to the printed total

def check_total(items, printed_total, tolerance=Decimal("0.01")):
    return abs(sum(items) - printed_total) <= tolerance
Enter fullscreen mode Exit fullscreen mode

For the example: 1200.00 - 300.00 + 15.00 + 80.00 = 995.00, which matches the printed total. Drop the parentheses handling and the sum becomes 1,595.00, and this check fails. That is the whole point: one wrong sign is caught by arithmetic you already have on the page.

Check 4: a header row appearing mid-table means a page break leaked in

def check_header_drift(rows):
    header = rows[0]
    return [i for i, r in enumerate(rows[1:], start=1) if r == header]
Enter fullscreen mode Exit fullscreen mode

On the example it returns [4]. Drop those rows before you sum anything, otherwise "Amount" ends up in your numeric column.

Put it together

def validate(rows):
    problems = []
    ok, widths = check_ragged(rows)
    if not ok:
        problems.append(f"ragged rows, widths {sorted(widths)}")
    drift = check_header_drift(rows)
    if drift:
        problems.append(f"header repeated at rows {drift}")
    return problems
Enter fullscreen mode Exit fullscreen mode

Run the checks in this order: structure first, then parsing, then the total. A structural problem makes the later numbers meaningless.

None of this is clever. It is about 30 lines, and it turns "the numbers look fine" into "the numbers reconcile".

This article was written with AI assistance and checked by the author.

Which table-extraction failure has bitten you most: shifted columns, lost signs or merged cells?

Top comments (0)