Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

Parsing bank statement PDFs in the browser, with a CSP that proves nothing gets uploaded

People who freelance often can't get a CSV out of their bank. Some banks only give PDF statements, others limit downloads to 90 days, and a few dropped CSV and QFX exports entirely. But every bookkeeping spreadsheet want

People who freelance often can't get a CSV out of their bank. Some banks only give PDF statements, others limit downloads to 90 days, and a few dropped CSV and QFX exports entirely. But every bookkeeping spreadsheet wants rows of Date, Description, Amount.

The usual fix is an online converter, which means uploading a year of bank statements to a stranger's server. I didn't want to ask anyone to do that, so the converter I built for Paid & Filed runs entirely in the browser tab: paidandfiled.com/bank-statement-converter. This post covers the three parts that were harder than I expected.

1. Turning pdf.js text runs back into lines

page.getTextContent() doesn't give you lines. It gives you runs of text, each with a transform matrix, in whatever order the PDF generator wrote them. A single statement row like

03-14  ACH DEPOSIT CLIENT CO       1,250.00     4,310.27

can arrive as six runs, out of order, with the minus sign of a debit split into its own run.

So the first step rebuilds lines geometrically: sort by baseline (transform[5], top to bottom) then x position, group runs whose baselines are within 2 points, and join runs that physically touch without a space so a word pdf.js split stays whole.

export function itemsToLines(items: PdfTextItem[]): string[] {
  const runs = items
    .filter((i) => i.str?.trim() && i.transform)
    .map((i) => ({ x: i.transform![4], y: i.transform![5], w: i.width ?? 0, s: i.str! }))
    .sort((a, b) => b.y - a.y || a.x - b.x);

  const lines: { y: number; runs: typeof runs }[] = [];
  for (const run of runs) {
    const last = lines[lines.length - 1];
    if (last && Math.abs(last.y - run.y) <= 2) last.runs.push(run);
    else lines.push({ y: run.y, runs: [run] });
  }

  return lines.map(({ runs }) => {
    let text = '';
    let end = -Infinity;
    for (const run of runs.sort((a, b) => a.x - b.x)) {
      const touching = run.w > 0 && end > -Infinity && run.x - end < 0.6;
      text += (text && !touching ? ' ' : '') + run.s;
      end = run.w > 0 ? run.x + run.w : -Infinity;
    }
    return text.replace(/\s+/g, ' ').trim();
  }).filter(Boolean);
}

A page that comes back with fewer than 40 non-space characters is a scan or a photo. There's no text layer to read, so the UI says so instead of returning an empty file.

2. One generic reader instead of a template per bank

The obvious design is a template per bank. I started from code that worked that way, and it falls apart quickly: the same bank changes layout between checking and savings, between years, and between the web download and the mailed PDF.

The generic rule turned out to be simple. A transaction line starts with a date and ends with one or two amounts. If there are two, the last one is the running balance. Everything in between is the description. Dates can be 03/14, 03-14, Mar 14 or 14 Mar 2026, and when a row has no year it comes from the statement period printed in the header, with care for December statements that run into January.

The hard part is the sign. Bank PDFs show direction in at least five ways: separate debit and credit columns (which collapse into one column once you've flattened the page to text), a trailing minus (45.10-), CR and DR codes, section headings like "Deposits and other credits", or nothing at all. So direction is decided in priority order:

let direction: 'in' | 'out' | null = null;

// 1. The running balance: if it went up by exactly this amount, it's money in.
if (running != null && row.balance != null) {
  const delta = cents(row.balance) - cents(running);
  const v = cents(row.value);
  if (delta === v) direction = isCard ? 'out' : 'in';
  else if (delta === -v) direction = isCard ? 'in' : 'out';
}
// 2. CR / DR / DEP / WD codes next to the amount.
if (!direction && row.marker) direction = row.marker;
// 3. A printed sign (a minus means money out on a bank statement, a payment on a card).
if (!direction && row.printedSign) direction = /* ... */;
// 4. The section heading the row sits under.
if (!direction && row.heading) direction = row.heading;
// 5. Give up, call it money out, and flag it for the user.
if (!direction) { direction = 'out'; row.guessed = true; }

Two details matter here:

  • Everything is compared in integer cents. 0.1 + 0.2 problems are real when you're summing 300 rows.
  • Credit cards run backwards. A purchase raises the balance on a card and lowers it on a checking account, so the reader decides "card or bank" first. It looks for phrases like "minimum payment due" or "credit limit", but only before the first transaction row. A savings statement with a line-of-credit footer under it was read as a card, with every sign flipped, until that rule went in.

3. The balance check

Statements print an opening and a closing balance. After parsing, the reader checks that opening plus money in minus money out equals the closing balance, to the cent. The UI shows a green "matches the statement" or the exact difference. That one check catches nearly every parsing bug: a row that wrapped onto two lines, a page footer read as a transaction, a sign guessed wrong.

When some rows had to be guessed and the totals don't add up, it tries one more thing: if flipping all the guessed rows to money in makes the balance match, it does that. It's a cheap heuristic that fixed a whole class of statements with unlabeled credit columns.

On real statements from one credit union (29 PDFs covering two years of checking, savings and loan statements), every checking statement matched to the cent on the first run. That test also exposed the card/savings bug above and the fact that loan statements have nothing to convert, so they now get their own message instead of a silent empty result.

Proving it never uploads anything

"Your file never leaves your browser" is easy to write and impossible for a visitor to verify. So the page carries a Content Security Policy that makes it true at the browser level. The site is static files on a Cloudflare Worker, and this lives in _headers:

/bank-statement-converter/
  Content-Security-Policy: connect-src 'self' https://cloudflareinsights.com; form-action 'self'

connect-src covers fetch, XMLHttpRequest, WebSockets, sendBeacon and EventSource, so the only place this page can send data is its own origin (which serves static files and has no upload endpoint) and the privacy-friendly analytics beacon. A future change that accidentally added a third-party script with tracking would be blocked by the browser, and it would show up in DevTools as a CSP violation.

A few things I had to get right for that to hold:

  • pdf.js is self-hosted (copied from node_modules into /public/pdfjs/<version>/ at build time), worker included, so loading it isn't a cross-origin request.
  • isEvalSupported: false in getDocument, which keeps pdf.js from compiling fonts with new Function().
  • Links into the page are plain <a> tags, not client-side router links. A client-side navigation never loads the page fresh, so the browser never applies the page's own CSP header. A full page load does.
  • The Pinterest tag the rest of the site uses is skipped on this path.

Password-protected PDFs work too. pdf.js throws a PasswordException and the UI asks for the password, which goes straight back into getDocument and nowhere else.

The output

The result downloads as CSV or Excel, or copies as tab-separated text so a paste into column A of a spreadsheet fills three columns. The .xlsx is written in the browser as well, with a small zip writer, so nothing round-trips to a server for that either.

If you want to try it, there's a "made-up statement" button on the converter page, so you don't need to feed it your own bank PDF to see how it works. It's free with no sign-up. Paid & Filed is the small business I run that makes a self-employed tax workbook, and the converter exists because buyers kept getting stuck at the "now paste your bank CSV" step.

Questions about the parser or the CSP approach are welcome in the comments.

πŸ“° 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.