DEV Community

Cover image for I built a bank statement to Excel converter that never uploads your PDF
Philip
Philip

Posted on

I built a bank statement to Excel converter that never uploads your PDF

Most "PDF bank statement to Excel" sites want you to upload the file. Think about what's on that PDF: your name, your address, your account number, and every payment you made last month. I wouldn't hand that to a site I'd never heard of, so when I built one for my own site I set one rule before writing any code. The file never leaves the browser tab.

This is how it works, including the parts that are still fragile. It's plain JavaScript, one ES module of about 230 lines plus Mozilla's pdf.js, with no backend at all. If you'd rather try it before reading, it's live as a free bank statement to Excel converter on my studio's site.

Reading text out of a PDF

A PDF doesn't contain lines or tables. It contains text fragments, each with a position on the page, and the viewer simply draws them where they belong. pdf.js hands you those fragments through getTextContent(), and every item carries a transform matrix whose last two values are its x and y coordinates.

I self-host pdf.js (the module and its worker are about 1.7 MB together) instead of loading it from a CDN, because "nothing leaves the tab" should also mean no third party learns that you opened the tool. It's loaded with isEvalSupported: false as well, which stops pdf.js from compiling code with eval; that path was behind CVE-2024-4367, and a tool that opens files from strangers' banks has no reason to keep it on.

PDF coordinates start at the bottom left, so a higher y is further up the page, hence b.y - a.y. Fragments within 2.5 units of each other vertically count as one line. That number came from trial and error: banks don't always put a date and its amount on exactly the same baseline, and with a tolerance of zero half the rows split in two.

Which lines are transactions?

A line counts as a transaction when it starts with a date and contains at least one money amount. Both of those turned out to be harder than they sound.

The parser knows five date shapes: 2026-10-03, 03/10/2026, 3 Oct 2026, Oct 3, 2026, and a short 03/10 with no year at all. The month names include the Dutch ones (mrt, mei, okt), because I'm based in Rotterdam and wanted Dutch statements to work from day one.

Then there's the classic problem. Is 03/10/2026 the 3rd of October or the 10th of March? One date can't tell you, but a whole statement usually gives it away:

let dmy = 0, mdy = 0;
for (const l of lines) {
  const m = l.cells.map((c) => c.s).join(' ').match(/^(\d{1,2})[./-](\d{1,2})[./-]\d{2,4}/);
  if (m) { if (+m[1] > 12) dmy++; if (+m[2] > 12) mdy++; }
}
Enter fullscreen mode Exit fullscreen mode

A date whose first number is above 12 has to be day first, and the other way round. If a statement only covers the first twelve days of a month there's no evidence either way, and the tool falls back to looking for a dollar sign or "USD" near the top of the document. That fallback is a guess, so the detected order is shown on screen next to a dropdown to override it.

Amounts are one long regex. It accepts an optional currency sign, thousands separators (comma, dot, space or apostrophe, since Swiss statements write 1'250.00), exactly two decimals and an optional trailing flag. The flag matters more than I expected, because plenty of statements never print a minus sign and put CR or DR after the number instead, or Af and Bij on Dutch ones. Requiring exactly two decimals is what keeps reference numbers and page numbers from being read as money.

Debit, credit or balance?

A transaction line often holds two or three amounts: the money going out or coming in, and the running balance. So the parser first looks for a header row (Debit, Withdrawals, Paid out, Bedrag af, Balance, Saldo and so on) and remembers the horizontal centre of each heading. Every amount on a transaction line then goes to the column whose heading is nearest in x. Under "Paid out" it becomes negative, under "Paid in" positive, and under "Balance" it lands in the balance column.

Without a recognisable header it falls back to a cruder rule, where the first amount is the transaction and the last one is the balance. That's right more often than you'd think, and wrong often enough that every cell in the results table is editable.

Two smaller rules clean up the rest. Lines like "Opening balance" or "Balance brought forward" are skipped, since to everything above they look exactly like transactions. And a line with no date and no amount, shorter than 120 characters, is appended to the previous transaction's description, which keeps descriptions that wrap onto a second line in one cell.

Writing an .xlsx without a library

CSV was easy, as long as you put a byte order mark in front; without it, Excel on Windows mangles every character that isn't ASCII. For a real .xlsx I didn't want a second large dependency next to pdf.js just to write a file with four columns.

An .xlsx is a zip archive of XML files, and a zip doesn't have to be compressed. The "stored" method writes each file behind a small header with a CRC-32 checksum and finishes with a central directory that lists them all. My writer is about 40 lines, plus six XML files as template strings: the content types, two relationship files, the workbook, a style sheet and the sheet itself.

The only part that isn't obvious is dates. Excel stores a date as a number of days since its own epoch, so converting a JavaScript date is one line:

const serial = (d) => d.getTime() / 86400000 + 25569;
Enter fullscreen mode Exit fullscreen mode

25569 is Excel's serial number for 1 January 1970. The value only displays as a date because the cell points at a style with the format yyyy-mm-dd; leave the style out and the cell shows something like 46298, which is correct and completely useless. The header row is frozen, the description column is wide, and descriptions are written as inline strings, so there's no shared-strings table to maintain.

How do you know nothing is uploaded?

You shouldn't have to take my word for it. The module reads the file with file.arrayBuffer(), parses it in memory and builds the download with URL.createObjectURL(). Nothing in it sends the file, its text or a single number anywhere. The site's Content Security Policy only allows connections back to its own domain, and if you keep the Network tab open while converting a statement, you won't see a request carrying your data. "Clear" empties the table, and nothing is written to storage at any point.

What it doesn't do yet

Scanned statements don't work. When a PDF is a picture of a page there's no text layer for pdf.js to read, and the tool says so instead of guessing. OCR in the browser is possible, but it would make the page a lot heavier, and I'm honestly not sure it's worth it for a free tool.

There's also a bug I know about and haven't fixed. Short dates without a year take their year from the first four-digit year found anywhere in the document. So a statement running from December into January, from a bank that prints dates that way, puts the January transactions in the wrong year. It's an edge case, but it's exactly the kind that ends up in somebody's annual accounts, which is why the tool shows you every row before it lets you download.

Every bank lays out its statement a little differently, so I expect to find more of these. If one of yours comes out wrong, the editable table will get you through it today, and I'd really like to hear which bank it was.


I'm Philip, a Microsoft-certified Power BI analyst from Rotterdam. I recently launched Flippie Labs, where I build dashboards and reporting automation for B2B teams. The bigger version of this problem, where invoices, receipts and statements arrive as PDFs and need to become clean tables every month, is what my documents-to-data service is for.

Top comments (0)