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 ext
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"],
]
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
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
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
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]
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
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?
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.