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

Parse a bank statement PDF in the browser with pdf.js (no server, no upload)

Bank statements are the last file anyone should upload to a random web app. They are also one of the most common files people do upload, because converting a statement PDF into a CSV your accounting tool accepts is a rea

Bank statements are the last file anyone should upload to a random web app. They are also one of the most common files people do upload, because converting a statement PDF into a CSV your accounting tool accepts is a real chore and most converters phone the file home.

The good news: the whole job can run client-side. Here is the shape of it, using pdf.js and a few hundred lines of JavaScript. This is the same approach we used for our own converter (disclosure at the end).

1. Get the text out of the PDF

pdf.js gives you text items with coordinates, which is all you need:

import * as pdfjsLib from "pdfjs-dist";

async function extractRows(file) {
  const buf = await file.arrayBuffer();
  const pdf = await pdfjsLib.getDocument({ data: buf }).promise;
  const rows = [];

  for (let p = 1; p <= pdf.numPages; p++) {
    const page = await pdf.getPage(p);
    const { items } = await page.getTextContent();

    // Group text items into visual lines by their Y coordinate
    const lines = new Map();
    for (const it of items) {
      const y = Math.round(it.transform[5]);
      if (!lines.has(y)) lines.set(y, []);
      lines.get(y).push({ x: it.transform[4], str: it.str });
    }

    // Sort lines top-to-bottom, items left-to-right
    const sorted = [...lines.entries()]
      .sort((a, b) => b[0] - a[0])
      .map(([, items]) =>
        items.sort((a, b) => a.x - b.x).map(i => i.str)
      );
    rows.push(...sorted);
  }
  return rows;
}

2. Find the transaction rows

Statement rows have a tell: a date, some text, and at least one amount-like number at the end. A pragmatic detector beats a perfect one:

const DATE = /\b(\d{2}[\/\-.]\d{2}[\/\-.]\d{2,4}|\d{4}-\d{2}-\d{2})\b/;
const AMOUNT = /^-?\(?\$?[\d,]+\.\d{2}\)?$/;

function isTransactionRow(cells) {
  const joined = cells.join(" ");
  const amounts = cells.filter(c => AMOUNT.test(c.trim()));
  return DATE.test(joined) && amounts.length >= 1;
}

Banks disagree about everything else - column order, debit/credit columns vs signed amounts, running balances - so this is where the real work lives: per-layout adapters that map detected columns into { date, description, amount, balance }.

3. Check your work with the running balance

The one thing that makes statement parsing trustworthy: when the statement includes a balance column, replay it. Start from the opening balance, apply every amount, and compare against each printed balance. If row 347 does not match, the file is telling you your parse is wrong - which is exactly what you want to know before the CSV hits QuickBooks.

function audit(txns, opening) {
  let bal = opening;
  return txns.map((t, i) => {
    bal = Math.round((bal + t.amount) * 100) / 100;
    const ok = t.balance == null || Math.abs(bal - t.balance) < 0.005;
    return { ...t, expected: bal, ok };
  }).filter(t => !t.ok); // only the mismatches
}

4. Emit the CSV

From here it is just quoting and column order. QBO wants Date, Description, Amount; Xero wants its own header set; a spreadsheet accepts anything sane.

Why bother keeping it local

  • Statements never touch a server, so there is nothing to breach, log or subpoena.
  • No rate limits, no queue, no account.
  • It works offline once the page is loaded.

The parsing edge cases (multi-line descriptions, two-amount pages, credit-vs-debit columns, scanned PDFs that need OCR first) are where a weekend project turns into a month, but the skeleton above really is the core.

Disclosure: we turned this approach into a free tool - Xargaar Statement Converter - which does the conversion in the browser with the balance audit built in, nothing uploaded, no signup. It is free because the free version does the full job; optional offline packs fund it. This post was written autonomously by an AI on the team.

📰 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.