Bank statements are about the most sensitive documents a small business has: account numbers, salaries, vendor names, balances. Yet the usual way to turn a statement PDF into a spreadsheet is to upload it to a server you know nothing about.

You don't have to. Modern browsers can open, decrypt and parse a text-based PDF entirely on the client. This post walks through the approach for Indian bank statements: extracting text with PDF.js, rebuilding rows from coordinates, handling Indian number and date formats, and verifying the result with a running-balance check.

Why do it client-side

  • Privacy by architecture. If the file never leaves the device, you don't have to promise anything about storage or retention. You can verify it yourself in the DevTools Network tab.
  • No server costs per page. Parsing load sits on the user's machine.
  • Works offline once the app is loaded.

The trade-off is that you ship the parser to the client and must keep it fast on modest laptops. Run the heavy work in a Web Worker so the UI stays responsive.

Step 1: Open the PDF, including password-protected ones

Most Indian banks password-protect e-statements. PDF.js handles standard PDF encryption if you pass the password:

import * as pdfjsLib from "pdfjs-dist"; async function openPdf(arrayBuffer, password) { const task = pdfjsLib.getDocument({ data: arrayBuffer, password }); try { return await task.promise; } catch (err) { if (err.name === "PasswordException") { // ask the user for the password (again), then retry throw new Error("PASSWORD_REQUIRED"); } throw err; } } 

The File comes from an <input type="file"> or a drop zone; await file.arrayBuffer() gives you the bytes. Nothing touches the network.

Step 2: Get text items with positions

PDFs don't store tables. They store text fragments placed at coordinates. getTextContent() returns those fragments, and transform[4] and transform[5] give the x and y position:

async function pageItems(pdf, pageNo) { const page = await pdf.getPage(pageNo); const { items } = await page.getTextContent(); return items .filter(it => it.str.trim() !== "") .map(it => ({ text: it.str.trim(), x: it.transform[4], y: it.transform[5] })); } 

Step 3: Rebuild lines and columns

Group items into lines by y (with a small tolerance, since baselines wobble), then sort each line by x:

function toLines(items, tol = 2) { const lines = []; for (const it of items.sort((a, b) => b.y - a.y || a.x - b.x)) { const line = lines.find(l => Math.abs(l.y - it.y) <= tol); line ? line.items.push(it) : lines.push({ y: it.y, items: [it] }); } lines.forEach(l => l.items.sort((a, b) => a.x - b.x)); return lines; } 

To assign columns, find the header line ("Date", "Narration", "Withdrawal", "Deposit", "Balance", or the bank's variant), record the x-range of each header, and drop each item into the column whose range it falls in. Each bank lays this out differently, so in practice you keep a small layout profile per bank rather than one universal rule.

Two things that will bite you:

  • Multi-line narrations. UPI and NEFT narrations often wrap. A line with no date in the date column is usually a continuation, so append its text to the previous transaction.
  • Noise rows. Page headers, "Opening Balance", "Carried Forward" and footers need explicit skip rules. Be conservative: dropping a real transaction is worse than keeping a junk row you can flag.

Step 4: Normalise Indian amounts and dates

Indian statements group digits as lakhs and crores (1,25,000.00) and may mark debit/credit with Dr/Cr. Never use floats for money. Convert to integer paise:

function toPaise(s) { if (!s) return 0; const neg = /dr|-/i.test(s); const clean = s.replace(/[^0-9.]/g, ""); const [r, p = "0"] = clean.split("."); const v = parseInt(r || "0", 10) * 100 + parseInt(p.padEnd(2, "0").slice(0, 2), 10); return neg ? -v : v; } 

Dates arrive as 04/05/2026, 04-05-2026 or 04-May-26. Parse them explicitly as day-month-year and produce ISO strings yourself. Don't hand ambiguous strings to new Date() or to Excel's auto-detection, or 4 May becomes 5 April.

Step 5: Verify with the running balance

This is the step that turns "probably right" into "provably right". Every statement row carries a balance, so each row must satisfy:

previous balance + credit − debit = balance 
function checkBalances(rows, opening) { let prev = opening; return rows.map(r => { const expected = prev + r.credit - r.debit; const ok = expected === r.balance; prev = r.balance; // resync so one error doesn't cascade return { ...r, ok, diff: r.balance - expected }; }); } 

Because everything is in paise, the comparison is exact. A mismatch almost always means a missed row, a swapped debit/credit, or a misread amount, and it points the user to the exact line. Show these flags in the UI rather than hiding them; accountants trust output they can check.

Step 6: Export

From clean rows you can write CSV directly, build .xlsx with a library such as SheetJS or ExcelJS (store dates as real date cells and amounts as numbers), or generate Tally Prime XML vouchers. Create the file as a Blob and trigger a download with URL.createObjectURL. Still no server involved.

Limits worth stating

This approach needs a text-based PDF. Scanned statements are images and need OCR, which is a different and much less exact problem. Per-bank layouts also drift over time, so keep test fixtures (with the sensitive data redacted) for each bank and run them in CI.

Wrapping up

Client-side PDF parsing is a good fit for financial documents: users get privacy without having to take your word for it, and a running-balance check gives them a way to verify every row. If you'd like to see this pattern in a finished product, StatementDock (https://statementdock.com/) converts Indian bank statements to Excel, CSV and Tally XML in the browser this way.

Lourdes Paul Agilan is the founder of StatementDock, built by Orosfort Private Limited in Chennai.