A financial statement PDF is a tree of labels and numbers, and turning it into rows you can trust takes a few things done carefully: parse every accounting-style negative into a real signed number, know exactly which amount column you kept, and check every printed subtotal against the lines under it, with a small, bounded allowance for rounding on whole-dollar statements. Get those right and most bad reads announce themselves before anyone builds a spreadsheet on them.
I'm Damin and I run ZeraBooks, whose Convert product reads balance sheets, P&Ls and cash flow statements from PDFs. The code below is a standalone sketch of the ideas, not our implementation.
Why does (1,234) have to become -1234?
Accountants write negatives at least four ways: (2,140), 2,140-, 2,140 CR and occasionally -2,140. A naive float("(2,140)") throws, and a copy-paste into a spreadsheet leaves text that SUM silently skips. Either way the statement stops footing, and whoever checks it hunts for a typo that isn't there.
The parser normalises first, stripping currency signs and thousands separators, then looks for each convention. Decimal, not float, because statements are exact and you'll be comparing for equality.
One judgment call is CR. On a bank statement it usually means money in; on statements where the printed convention is "CR means negative", it's a negative. The sketch treats it as negative, and you should make that choice explicitly for your documents rather than by accident.
Which column did you keep?
Comparative statements print two or more amount columns. If you keep one, you must know which, and "the rightmost" isn't safe on its own: plenty of statements print the current year on the left and the prior year on the right.
pick_column prefers a column headed Total, common on monthly P&Ls with twelve months and a total, then falls back to the rightmost, and always returns the heading so you can print it. In the sample below it picks 2024, which is exactly the trap. Everything foots and balances, and it's last year's numbers. Show the heading every time and let a person catch it.
How much rounding is allowed?
Whole-dollar statements are produced from cents and rounded line by line, so a printed total can differ from the sum of its printed lines by a dollar or two without anything being misread. Allow nothing and you raise false alarms on honest statements. Allow a flat $10 and you hide a real $7 misread.
The rule here scales with the group: up to max(1, ceil(sqrt(n))) dollars for a group of n lines, and only when every amount in the group is a whole number. Four lines get $2; nine get $3. Statements with cents get no allowance at all.
The code
import math
from decimal import Decimal
def parse_amount(text):
"""'(2,140)' -> -2140, '1,234.56-' -> -1234.56, '88 CR' -> -88, '' or '-' -> None"""
t = text.strip().replace("$", "").replace(",", "")
if t in ("", "-"):
return None
neg = False
if t.startswith("(") and t.endswith(")"):
neg, t = True, t[1:-1]
elif t.endswith("-"):
neg, t = True, t[:-1]
elif t.upper().endswith("CR"):
neg, t = True, t[:-2].strip()
elif t.startswith("-"):
neg, t = True, t[1:]
value = Decimal(t)
return -value if neg else value
def pick_column(headers):
"""Prefer a Total column; otherwise the rightmost. Return the index and its heading."""
for i, h in enumerate(headers):
if h.strip().lower() == "total":
return i, h
return len(headers) - 1, headers[-1]
def tolerance(lines, printed):
"""Whole-dollar statements may round: allow max(1, ceil(sqrt(n))) dollars. Otherwise exact."""
if all(a == a.to_integral_value() for a in lines + [printed]):
return Decimal(max(1, math.ceil(math.sqrt(len(lines)))))
return Decimal("0")
def check(name, lines, printed):
got = sum(lines, Decimal("0"))
off = printed - got
tol = tolerance(lines, printed)
status = "ok" if abs(off) <= tol else "NEEDS A LOOK"
return f"{name:<16} lines {got:>9} printed {printed:>9} off {off:>3} allowed {tol} {status}"
# Quarry Hill Bakery (fictional), profit and loss, whole dollars, two years side by side
headers = ["2025", "2024"]
rows = {
"Retail sales": ["388,412", "351,970"],
"Wholesale": ["96,305", "88,140"],
"Total revenue": ["484,717", "440,110"],
}
col, heading = pick_column(headers)
print(f"using column {col} ({heading})")
# Read the 2025 column on purpose, then check every group
y = 0
rev = [parse_amount(rows[k][y]) for k in ("Retail sales", "Wholesale")]
total_rev = parse_amount(rows["Total revenue"][y])
expenses = [parse_amount(x) for x in ("152,880", "201,455", "48,000", "19,632")]
total_exp = parse_amount("421,968")
other = [parse_amount(x) for x in ("415", "(2,140)")]
total_other = parse_amount("(1,725)")
net_income = parse_amount("61,025")
print(check("Revenue", rev, total_rev))
print(check("Expenses", expenses, total_exp))
print(check("Other", other, total_other))
print(check("Net income", [total_rev, -total_exp, total_other], net_income))
Running it on a fictional bakery
Quarry Hill Bakery is made up. Its 2025 P&L prints whole dollars with 2024 beside it. Revenue is retail 388,412 and wholesale 96,305, total 484,717. Expenses are ingredients 152,880, wages 201,455, rent 48,000 and utilities 19,632, printed total 421,968. Other income and expense: interest earned 415 and a loss on equipment disposal printed as (2,140), total (1,725). Net income 61,025.
$ python3 fin.py
using column 1 (2024)
Revenue lines 484717 printed 484717 off 0 allowed 2 ok
Expenses lines 421967 printed 421968 off 1 allowed 2 ok
Other lines -1725 printed -1725 off 0 allowed 2 ok
Net income lines 61024 printed 61025 off 1 allowed 2 ok
Line one is the comparative-column warning: left to the default, the code would have read 2024. The script then reads the 2025 column on purpose.
Expenses add to 421,967 against a printed 421,968. One dollar, inside the $2 allowed for four lines, and exactly the kind of difference rounding produces. Net income shows the same thing one level up: 484,717 - 421,968 - 1,725 = 61,024 against a printed 61,025. Rounding shows up twice because each total was rounded separately from its lines.
The Other group is where parentheses matter. Read (2,140) as +2,140 and the group sums to 2,555 against a printed -1,725, off by 4,280. That's twice the line, the signature of a dropped sign.
What this doesn't cover
- Indentation, which on a real PDF tells you which lines belong to which total. You need it to build the groups this sketch hard-codes.
- Cross-statement checks: net income on the P&L equal to net income in the balance sheet's equity section, and retained earnings rolling forward year to year.
- Cash flow statements, where the net change must equal operating plus investing plus financing, and ending cash must equal beginning cash plus the net change.
- Scans, where the hard part is reading the page at all.
In production
Disclosure: I'm Damin and I run ZeraBooks, so weigh this part accordingly. ZeraBooks Convert reads P&Ls, balance sheets and cash flow statements from PDFs, scanned ones included, and downloads Excel or CSV in one format: Statement, Section, Line, Amount and Total. Parentheses, a trailing minus and CR come through as negatives. It checks every total against its lines, the P&L's own formulas, assets against liabilities plus equity, and the cash flow's net change and ending cash, allowing a few dollars of rounding only on whole-dollar statements. It keeps a Total column when there is one, otherwise the rightmost, and shows that column's heading. It's part of the ZeraBooks subscription, 1,000 pages a month for $79 a month billed annually, with a 7-day trial.
Worked examples in this layout, a full balance sheet and income statement with the checks to run afterwards, are in our guide to financial statement PDF to Excel. The rest of Convert is on the bank statement converter page.
If you only add one thing to your parser, make it the column heading in the output. It's the error the arithmetic can't catch.
Top comments (1)
The sqrt(n) allowance held up better than I expected. I simulated it: n lines of random cents, each rounded to whole dollars, against the total rounded from the exact cents, 200,000 trials per size. For 4 lines the gap never went past your $2 (max seen 2). For 9 lines it passed $3 once in 200,000 (max 4). For 25 lines it passed $5 in 20 of 200,000, about 0.01% (max 6).
So honest statements almost never trip it, but it is a typical-case rule rather than a bound: the true worst case grows like n/2. If the flag prints the size of the miss next to the allowance, a $4 miss on nine lines reads as "check rounding" instead of "misread". Do you count how many flags land just over the allowance? That is where I would look first.