I needed to pull transaction rows out of a bank statement PDF, inside a browser, with no server and no 1 MB pdf.js bundle. What I learned is that the hard part is not reading the bytes — it is that a PDF has no concept of a line.
The naive approach, and why it returns one giant string
A simple text extractor walks the content stream and collects whatever sits inside (...) Tj and [...] TJ operators. That part works. Then you get this:
ACME BANK 01/05/2026 GROCERY STORE #12 45.20 01/06/2026 SALARY 2,450.00 CR 01/07/2026 ELECTRIC 120.75
Eight transactions, zero newlines. T* is a layout instruction — it moves the cursor, it does not emit a character. If your parser splits on \n, you get one row and you will spend an hour thinking the extractor is broken.
Fix one: split on a business anchor, not on whitespace
Do not try to reconstruct lines from PDF operators. Find a token that only appears at the start of every record. For statements that is the date.
// collect every date occurrence, then slice between them
const anchors = [];
for (const pat of DATE_PATTERNS) {
for (const m of text.matchAll(pat)) anchors.push({ start: m.index, end: m.index + m[0].length, date: m[0] });
}
anchors.sort((a, b) => a.start - b.start);
for (let i = 0; i < anchors.length; i++) {
const seg = text.slice(anchors[i].end, anchors[i + 1]?.start ?? text.length);
// seg is one transaction, minus the date
}
The second benefit: a record that has no date is not a transaction, so it gets dropped for free.
Fix two: the amount regex has to defend against the previous row
This is the bug that cost me the most time. Amounts sit directly against the next date:
... GROCERY STORE #12 45.2001/06/2026 SALARY ...
A naive \d+\.\d{2} matches 45.2001. You then record a transaction worth four hundred and fifty point two — silently, with no error.
Two things fix it, and you need both:
-
Strip dates out of the segment before looking for amounts, so
01/06cannot be read as a number. - Put boundaries in the regex so a number cannot start or end inside another number:
const AMOUNT_RE = /(?<![\d/])(?:[$€£]\s?)?(-?\d{1,3}(?:,\d{3})+(?:\.\d{2})?|-?\d+\.\d{2})(?![\d/])/g;
That (?<![\d/]) is doing the real work. Without it, 45.2001 is a valid match.
Fix three: CR and DR are not signs
A statement writes 2,450.00 CR. Your parser sees 2,450.00 and throws away the CR, so a deposit becomes a withdrawal. Read the two characters after the amount:
const tail = seg.slice(matchEnd, matchEnd + 8);
if (/\bCR\b/i.test(tail)) amount = Math.abs(amount);
else if (/\bDR\b/i.test(tail)) amount = -Math.abs(amount);
What I tested, and one honest limitation
Ten assertions: 8 rows found, ISO dates, CR positive, thousands separators parsed, no digits left in the description, CSV row count, and total equals the hand-computed sum.
The limitation: this only works on PDFs that have a text layer. A scanned statement is an image, and I did not add OCR — the whole point of this tool was to stay small enough to run in a browser tab.
The smallest working extractor
function extractText(pdfBytes) {
const out = [];
for (const m of pdfBytes.matchAll(Buffer.from('stream\r?\n(.*?)endstream', 's'))) { /* ... */ }
// inflate each stream, then pull the strings out of Tj / TJ
}
That is roughly 30 lines with zlib. No dependency, no WebAssembly, no worker.
If you are debugging a similar extractor: the failure mode is almost never the inflate step. It is the assumption that the text has structure it does not have.
Top comments (0)