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 | Price above Item | 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.