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.
Top comments (0)