A bank statement is about as private as a document gets: your name, your address, your account number, and a list of everywhere you spent money. Yet the usual way to turn one into a spreadsheet is to upload it to somebody's server.
I wanted a converter that never receives the file. Not "deletes it after an hour" or "encrypts it in transit". The file stays on your machine because there is no server to send it to. This post walks through how that works, including the parts that turned out to be harder than expected.
Disclosure: I build deskkit, where this tool lives. The techniques below are general, and you can use them without it.
The constraint: everything happens in the tab
The whole pipeline runs in the page:
- pdf.js reads the PDF and gives every piece of text along with its position on the page.
- If a page has no text layer (it's a scan), tesseract.js reads it with OCR in a Web Worker.
- A column grid separates real transaction rows from headers, summaries and side boxes.
- A running-balance check verifies the result arithmetically.
- A tiny XLSX writer builds the spreadsheet in memory and hands it to the browser as a download.
No request carries the file. The page does make two outside requests unrelated to your document: a web font from Google Fonts, and the cookie-less page-view counter that Cloudflare (our host) adds automatically. Both are listed on the site's privacy page. If you open DevTools → Network while converting, those are the only third-party hosts you'll see, and neither receives anything from the PDF.
Step 1: text with coordinates, not just text
The obvious approach is "extract all text, then regex it". It doesn't hold up. On real statements, a loose pattern caught an interest rate as a transaction in one file and a phone number in another. The text alone loses the one thing that makes a table a table: alignment.
pdf.js keeps it. page.getTextContent() returns items with a transform matrix, so every cell has an x and a y:
const content = await page.getTextContent();
const cells = content.items.map((it) => ({
text: it.str,
x: it.transform[4],
y: it.transform[5],
}));
Group cells whose y values are within a small tolerance and you have rows. The tolerance can't be a constant, though. It depends on the coordinate space (more on that below), and for OCR'd pages it comes from the OCR output itself.
Step 2: a column grid decides what is a transaction
Real transaction rows line up: dates in one column, descriptions in the next, amounts and the balance on the right. Headers, account summaries and marketing boxes don't line up with them.
So instead of asking "does this line look like a transaction?", the tool asks "does this line sit on the grid?":
- Take candidate rows (rows that contain a date and an amount).
- Build a histogram of their cell
xpositions. - The positions where many rows agree are the columns.
- Drop any row whose cells don't fall on those columns.
function findColumns(rows, threshold = 0.3) {
const xs = rows.flatMap((r) => r.cells.map((c) => c.x));
const spread = Math.max(...xs) - Math.min(...xs);
const bucket = Math.max(3, spread / 150); // proportional, not fixed
const counts = new Map();
for (const r of rows)
for (const c of r.cells) {
const k = Math.round(c.x / bucket) * bucket;
counts.set(k, (counts.get(k) || 0) + 1);
}
const min = Math.max(2, Math.ceil(rows.length * threshold));
return [...counts]
.filter(([, n]) => n >= min)
.map(([x]) => x)
.sort((a, b) => a - b);
}
The bug worth sharing: the bucket width. My first version used a fixed bucket of 8 units, and it worked on most files. Then one statement came in with a coordinate space roughly ten times larger (x from about 2,500 to 4,300 instead of 50 to 500). A fixed bucket split each real column across two neighbouring buckets, neither crossed the threshold, and the result was zero columns. Making the bucket proportional to the page's horizontal spread fixed it. PDFs don't agree on units, so never hard-code a pixel distance.
Step 3: scans go through OCR, in the same tab
When getTextContent() comes back nearly empty, the page is an image. The tool renders it to a canvas at roughly 300 DPI (OCR at 72 DPI is mostly guessing) and passes it to tesseract.js running in a Web Worker.
One detail matters for the "nothing leaves the tab" promise. By default, tesseract.js downloads its core, worker and language data from a public CDN. The file still wouldn't be uploaded, but every visitor's browser would contact a third party, and the claim would be wrong. So all three paths point at our own origin:
const worker = await createWorker('eng', 1, {
workerPath: '/vendor/ocr/worker.min.js',
corePath: '/vendor/ocr/',
langPath: '/vendor/ocr/',
});
The first scanned file is slower because the engine and language data download once; after that, the browser cache handles it. The UI tells you which pages were OCR'd and the engine's confidence. But confidence is an estimate, which is why the next step exists.
Step 4: let arithmetic check the extraction
Most statements have a running balance column. That gives you a free test: for every row, the change in the balance should equal the row's amount.
function checkBalanceChain(rows) {
// For each row: last money value = balance, the one before it = amount.
let checked = 0, matched = 0;
for (let i = 1; i < rows.length; i++) {
const prev = rows[i - 1], cur = rows[i];
if (cur.amount == null || prev.balance == null) continue;
checked++;
const delta = Math.abs(cur.balance - prev.balance);
if (Math.abs(delta - Math.abs(cur.amount)) < 0.011) matched++;
}
return { checked, matched };
}
The result is shown plainly: "All 42 balance checks add up", or "39 of 42 add up, look at the rows that don't". If OCR misreads a single digit, the chain breaks at that row, and you know exactly where to look. If a statement has no balance column, the tool says the rows are extracted but unverified, instead of implying they're fine.
Invoices get the same treatment from their own arithmetic: quantity × unit price has to equal each line's amount, and the lines have to add up to the invoice's own total. Instead of claiming "95% accuracy", the tool reports something like "12 of 12 lines add up".
Step 5: writing XLSX without a library
SheetJS is excellent, but it's about a megabyte, and the tools are meant to load fast. An .xlsx file is just a ZIP of a few XML files:
[Content_Types].xml
_rels/.rels
xl/workbook.xml
xl/_rels/workbook.xml.rels
xl/worksheets/sheet1.xml
Excel happily opens a ZIP written with no compression (the STORE method). That means the whole writer is string templates for those five files, a CRC32 function (the ZIP headers require it), and some byte-packing for the local file headers and the central directory. No compression library, no dependency. The resulting Blob goes to an <a download>, and the spreadsheet is created in the same tab that read the PDF.
What I'd tell anyone building something similar
- Coordinates beat regex for tables. Alignment is the signal; text patterns are the noise.
- Never trust a fixed distance in PDF space. Make tolerances proportional to the page.
- Self-host your WASM and models. "Runs locally" is false if the engine loads from a CDN.
- Give the user an arithmetic proof, not a confidence score. A balance chain that adds up is worth more than any OCR percentage.
- Say what you couldn't verify. When a statement has no balance column, the honest output is "extracted, unverified", not a green tick.
Try it
The converter is at deskkit.app/bank-statement-to-excel. The first page converts without an account, and there's a "Try a sample" button with a made-up statement if you don't want to use a real one. Open the Network tab while it runs and check the claims above for yourself.
I'd love to hear how others handle table detection in PDFs, especially descriptions that wrap onto a second line. If you've found a robust way to merge those rows, tell me in the comments.
Top comments (0)