DEV Community

Cover image for Six parsing changes that made invoice extraction trustworthy
Hao Zhang
Hao Zhang

Posted on

Six parsing changes that made invoice extraction trustworthy

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;
Enter fullscreen mode Exit fullscreen mode

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] });
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)