First version worked on my test invoice. Then a real accountant sent twelve.
It got the date on nine of them, the total on six, and the right total on four. The failures were not random — they clustered into six patterns, and fixing those six is what turned a demo into something I would let a bookkeeper use.
This is the second post in a series about DocExtract, where the PDF never leaves the browser. The first post covered the architecture and the walls; this one is about the specific parsing decisions.
1. Detect the text layer instead of assuming it
A PDF is a container. Some hold glyph positions; some hold a scanned JPEG. And the frustrating middle: a scanned page that still carries 20–40 characters of letterhead and a page number.
A naive check (if (textContent.items.length)) sends those to the text-layer parser, which then extracts a date and nothing else.
What worked was a character count per page with a threshold, plus a per-page decision instead of a per-document one:
const chars = items.reduce((n, it) => n + (it.str?.length || 0), 0);
const useTextLayer = chars > 120;
A 10-page PDF where page 1 is a cover letter and pages 2–10 are scans needs the decision per page, not per file.
2. Group rows with a tolerance, not equality
Text items arrive with x/y. A visual row is not a y value — the vendor name and the invoice number on "one line" were often 3 points apart.
const TOL = 6; // points ≈ 2mm at typical invoice scale
const rows = [];
for (const it of items.sort((a, b) => b.y - a.y)) {
const row = rows.find(r => Math.abs(r.y - it.y) <= TOL);
if (row) { row.items.push(it); row.y = (row.y + it.y) / 2; }
else rows.push({ y: it.y, items: [it] });
}
Six points fixed the three-row-instead-of-one problem without merging genuinely distinct table rows. It is the single highest-leverage constant in the whole parser.
3. Treat money as a locale problem
parseFloat fails on half of Europe:
EUR 170,00 €170.00 170,00 € 1 234,56 CHF 1'234.50
The rule that finally worked: the last separator is the decimal one.
function parseMoney(token, fallbackCurrency) {
const m = token.match(/(?:(EUR|USD|GBP|CHF|€|\$|£)\s*)?(\d[\d.,\s]*\d|\d)\s*(?:(EUR|USD|GBP|CHF|€|\$|£))?/i);
if (!m) return null;
const currency = m[1] || m[3] || fallbackCurrency;
let raw = m[2].replace(/[\s']/g, '');
const lastComma = raw.lastIndexOf(','), lastDot = raw.lastIndexOf('.');
raw = (lastComma > lastDot)
? raw.replace(/\./g, '').replace(',', '.')
: raw.replace(/,/g, '');
const value = parseFloat(raw);
return Number.isFinite(value) ? { value, currency } : null;
}
Note [\s'] — the Swiss apostrophe and the French thin space are both thousands separators, and both silently break every regex that only allows , and ..
4. Stop looking for "the number after the label"
The obvious strategy — find the word "Total", take the next number — fails constantly, because the number after "Total" is sometimes the VAT rate, sometimes the quantity column from the row above, sometimes a PO number.
What worked was: for each label, look at the same row, pick the right-most money-looking token on that row, and validate it:
const rowOf = (labelItem) => rows.find(r => r.items.includes(labelItem));
const candidates = rowOf(label).items
.filter(it => it !== label)
.map(it => parseMoney(it.str, defaultCurrency))
.filter(Boolean);
const total = candidates.at(-1)?.value ?? null;
Right-most rather than left-most because in nearly every invoice layout the amount sits to the right of its label. If the row has no money token, return null — do not fall through to the next row. Falling through was how "PO-4471" became the invoice total on one file.
5. A rate is not an amount
This one cost me the most trust, because the wrong answer looks completely plausible:
VAT 20% 170,00
A regex for "VAT" grabs 20. The spreadsheet says VAT = 20, the accountant reconciles against 170, and now nothing else in the file is trustworthy either.
The fix is two lines — reject any token immediately followed by %, and reject bare integers in a VAT context unless they carry a decimal part:
const isRate = (tok) => /^\s*%/.test(tok.after) || tok.text.includes('%');
const looksLikeAmount = (tok) => /\d[.,]\d{2}\b/.test(tok.text);
Same trap with German MwSt. 19 %, and with 0.20 written as 0,20.
6. Prefer a blank cell to a confident wrong number
The last change was cultural rather than technical: every extracted field carries its source.
In the text-layer path, values are marked exact. In the OCR path, they are marked as OCR. Line items are only attempted when the text layer exists — reconstructing qty × unit price from a scanned table is where accuracy collapses, and a half-wrong line-item table is worse than an empty one, because someone will import it.
Concretely:
- label not found →
null, not a guess - label found but no money token on its row →
null - OCR confidence below threshold on the amount → keep the number, flag the row
The output looks less impressive. It is also the reason a bookkeeper can trust the numbers that are there.
What still fails
Being specific about this is the point of the series:
- Handwriting. Printed text only. A handwritten receipt returns noise.
-
Merged-cell headers. A two-level header (
Description | Qty | PriceaboveItem | Unit | Total) makes row grouping merge things a human would not. - Multi-page tables. A line-item table continuing on page 2 loses its header and the parser treats those rows as a fresh area.
- Currency inference. If the invoice never states a currency and the locale is ambiguous, I return the amount without one rather than guessing.
The uncomfortable lesson
Every "improvement" I made past a certain point made the demo look better and the product worse: more fields extracted, more of them wrong. The six changes above are mostly about refusing to answer.
If you are building anything that turns documents into numbers: the field you leave blank is a feature.
DocExtract runs this parser entirely in the browser — sample invoices are on the site if you want to compare the digital path against the scanned one on your own machine. Free tier is 20 documents a month, no sign-up; paid packs are one-time, not a subscription.
Top comments (0)