Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Invoices to CSV, entirely in the browser: the five walls I hit

Some documents you are not allowed to upload. If you are a bookkeeper, an accountant, or anyone handling another person's invoices, "just send it to a converter in the cloud" is not an option. The client did not consent

Some documents you are not allowed to upload.

If you are a bookkeeper, an accountant, or anyone handling another person's invoices, "just send it to a converter in the cloud" is not an option. The client did not consent to their purchase history sitting in someone's S3 bucket.

So I built it the other way around: the PDF never leaves the machine. No upload endpoint, no server-side OCR, no per-document API cost. Everything runs in the tab.

This is a write-up of what that constraint actually costs you, because most "OCR in the browser" posts stop at Tesseract.recognize() and never mention the five walls that come after it.

The architecture in one paragraph

pdf.js opens the file and hands back both the rendered page and, if it exists, the text layer with per-item coordinates. If the text layer has enough characters, I parse those items directly — no OCR, no guessing, ~200ms for a normal invoice. If it doesn't (a scan, a photo, a fax), I render the page to a canvas and run tesseract.js over it in a worker. Export is xlsx for spreadsheets or a plain CSV blob.

Two paths, one output shape. The rest of this post is about why the second path is 20x slower and why the first one lies to you.

Wall 1: "PDF" does not mean "text"

The first assumption I got wrong: every PDF has text.

It does not. A PDF is a container. Some contain glyph positions, some contain a single JPEG of a piece of paper, and some contain glyph positions that are correct but useless because the file was produced by an OCR pass in 2009 that mangled them.

You cannot trust the file. You have to measure it:

const page = await pdfDoc.getPage(1);
const content = await page.getTextContent();
const chars = content.items.reduce((n, it) => n + (it.str?.length || 0), 0);

// A real text-layer invoice runs to hundreds of characters per page.
// A scanned page often still has 10-40 characters: a header, a page number.
const useTextLayer = chars > 120;

That threshold matters more than it looks. Scanned invoices frequently carry a few characters — the letterhead, a footer — which is enough to make a naive check say "text layer exists" and then produce an invoice with a date and nothing else.

Wall 2: coordinates are not a table

The text layer gives you spans with x, y, width. It does not give you rows, columns, or cells. A table is something a human perceives, not something the format encodes.

The naive approach is to group spans that share a y. That works until the first invoice where the vendor name and the invoice number sit 3 points apart vertically:

ACME SUPPLIES LTD          y = 712.4
INV-2026-0417              y = 709.1     <- 3pt lower, same visual line
2026-10-14                 y = 712.6

Sort by exact y and you get three rows instead of one. Group with a tolerance instead:

const rowTolerance = 6; // points

function groupRows(items) {
  const rows = [];
  for (const it of items.sort((a, b) => b.y - a.y)) {   // PDF y grows upward
    const row = rows.find(r => Math.abs(r.y - it.y) <= rowTolerance);
    if (row) {
      row.items.push(it);
      row.y = (row.y * (row.items.length - 1) + it.y) / row.items.length;
    } else {
      rows.push({ y: it.y, items: [it] });
    }
  }
  return rows.map(r => r.items.sort((a, b) => a.x - b.x));
}

Six points is roughly 2mm at typical invoice scale. Big enough to merge the three spans above, small enough not to merge two genuine table rows.

Wall 3: money is not a number

Then you try to pull the total out of the text and discover that "the number after the word Total" is a lie in most of Europe.

Total          EUR 170,00
Total          €170.00
Total          170,00 €
Summe          170,00 EUR

parseFloat handles none of these. What worked was a tokenizer that accepts a currency symbol or code on either side of the amount, normalises the separators, and is explicit about what it rejects:

const MONEY = /(?:(EUR|USD|GBP|CHF|€|\$|£)\s*)?(-?\d{1,3}(?:[.,\s]\d{3})*(?:[.,]\d{2})?)\s*(?:(EUR|USD|GBP|CHF|€|\$|£))?/i;

function parseMoney(token, defaultCurrency) {
  const m = token.match(MONEY);
  if (!m) return null;
  const currency = m[1] || m[3] || defaultCurrency;
  let raw = m[2].replace(/\s/g, '');

  // "1.234,56" (EU) vs "1,234.56" (US) — the last separator is the decimal one
  const lastComma = raw.lastIndexOf(',');
  const lastDot = raw.lastIndexOf('.');
  if (lastComma > lastDot) {
    raw = raw.replace(/\./g, '').replace(',', '.');
  } else {
    raw = raw.replace(/,/g, '');
  }
  return { value: parseFloat(raw), currency };
}

Note the whitespace class in the amount: 1 234,56 (thin space as thousands separator) is common in French and German invoices and silently breaks every regex that only allows , and ..

Wall 4: the VAT rate looks exactly like the VAT amount

You ask for "VAT" and the invoice gives you this:

VAT 20%          170,00

A regex looking for the number after "VAT" returns 20 — the rate, not the amount. That is the kind of bug that produces a spreadsheet that is plausible, wrong, and only caught months later during a reconciliation.

The fix is a one-line rule that took me an embarrassing amount of time to write:

// A number immediately followed by % is a rate. Never treat it as money.
const isRate = tok => /^\s*%/.test(tok.after) || tok.text.includes('%');

Same trap with 19, 7, 0.20 and MwSt. 19 % in German files.

Wall 5: memory, workers, and the tab that dies

Digital PDFs are cheap. A 20-page scan is not.

Each page at 300 DPI is a ~2500x3500 canvas — about 35 MB as RGBA. Ten of those cached at once will kill a mobile tab. Three things fixed it:

  • tesseract.js in a worker, never on the main thread, so the UI does not freeze
  • render one page at a time and canvas.width = 0 immediately after getImageData, instead of keeping pages around
  • cap the working resolution (230–300 DPI is the sweet spot; 600 DPI doubles runtime for no accuracy gain on printed invoices)

Real numbers from my own runs:

Document Path Time
Digital invoice, 1 page text layer under 1s
Scanned invoice, 1 page OCR, 300 DPI 3–5s
Scanned statement, 20 pages OCR, one page at a time 60–90s, ~250 MB peak

What it does not do

Worth stating plainly, because a tool that oversells itself is worse than one with a narrow claim:

  • Handwriting fails. Printed text only. A handwritten receipt returns noise, and you should not trust it.
  • Line items need a text layer. OCR gives you totals reliably; reconstructing qty × unit price from a scanned table is where accuracy falls off a cliff. I extract line items from digital PDFs and tell you when I cannot.
  • Merged-cell tables need manual fixing. If the invoice has a two-level header, the row grouping will merge things a human would not.
  • OCR is not deterministic across languages. English plus one other language works; genuine mixed-script documents do not.

That is the honest boundary. Inside it, the result is exact — it is not interpreted by a model, it is parsed.

What I would do differently

  1. Measure the text layer before promising anything. Most "it worked in testing" failures were scanned files that quietly had 30 characters.
  2. Treat money as a locale problem from day one, not as a parseFloat problem. The European formats are not edge cases, they are half the market.
  3. Say no out loud. Every feature I added past the honest boundary made the product look better and work worse.

If you want to poke at it, it is at receipt2csv.com — there are sample invoices on the page (a digital one and a scanned one) so you can compare the two paths without uploading anything, which is rather the point.

Free tier is 20 documents a month, no sign-up. The paid packs are one-time, not a subscription — because charging a bookkeeper monthly for a tool they use in January and then again in April never sat right with me.

Happy to answer anything about the parsing internals, the coordinate maths, or the parts I gave up on.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.