DEV Community

ii-x
ii-x

Posted on Originally published at ii-x.com

Extracting text from a PDF without a PDF library: the T* trap

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode

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:

  1. Strip dates out of the segment before looking for amounts, so 01/06 cannot be read as a number.
  2. 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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)