Series: Building PdfWord — a free, no-backend PDF tools site (Part 11)
"Convert my bank statement PDF to Excel" — one of the most requested features I've gotten, and one of the most technically dishonest-sounding. Here's the dirty secret of PDF-to-Excel: PDFs don't have tables. They have glyphs painted at coordinates. There are no rows, no columns, no cells — just text floating at (x, y) positions on a page.
So "extracting tables" really means reconstructing structure from positioned text. Here's how I do it entirely in the browser, with pdf.js and SheetJS, no server involved.
Try it: PDF to Excel
Step 1: Get positioned text from pdf.js
page.getTextContent() returns every text fragment on the page, each with a transform matrix [a, b, c, d, e, f] where e and f are the x/y position. (Note: PDF y-axis points up, so sorting needs care.) You also get the font size from transform[0] and the fragment width. That's your entire raw material.
Step 2: Reconstruct lines with groupLines()
This is the core algorithm, and it's pleasingly simple:
- Throw away empty fragments.
- Sort everything by y descending, then x ascending.
- Walk the sorted list: start a new line whenever the y-coordinate differs from the current line by more than a tolerance — I use
Math.max(2.5, fontSize * 0.32), so the tolerance scales with the text size. - Within each line, sort fragments by x and join them with gap-aware spacing — a wide gap between fragments usually means a column break, so it gets wider spacing in the output.
rows.sort((a, b) => b.y - a.y || a.x - b.x); // y down the page, x across
const tol = Math.max(2.5, r.size * 0.32); // tolerance scales with font size
if (!cur || Math.abs(cur.y - r.y) > tol) {
cur = { y: r.y, parts: [] }; lines.push(cur); // new line
}
The font-size-scaled tolerance is the bit I'm proudest of. A fixed 3px tolerance breaks on large headings; a pure relative tolerance breaks on tiny footnotes. The max() of the two handles both.
Step 3: Lines become a spreadsheet with SheetJS
Once you have lines of text, the Excel part is almost boring — which is a compliment to SheetJS:
const ws = XLSX.utils.aoa_to_sheet(rows); // array-of-arrays → worksheet
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Text');
const out = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
// → Blob download as .xlsx
I insert --- Page N --- separator rows between pages so multi-page PDFs stay navigable, and there's a progress bar since a 50-page statement takes a few seconds. The whole library (xlsx.full.min.js) is served locally — no CDN, so it works offline once cached.
The honest limitations
- Text-based PDFs only. A scanned statement is images, not text — the tool says so plainly ("No readable text found — this PDF may be scanned images only") and points you at the OCR tool instead of handing you an empty spreadsheet. A silent empty file is the worst possible output.
- It's reading order, not real tables. Merged cells, multi-line cells, and rotated text confuse the coordinate heuristic. What you get is clean, readable text in the right order — enough to work with, not a pixel-perfect table clone.
- Numbers come out as text, deliberately. Auto-detecting number formats sounds smart until it silently turns an account number into scientific notation. I'd rather you format the column yourself than discover corrupted data later.
For bank statements, invoices, and reports — the "rows of stuff" PDFs — it does the job. For a beautifully typeset annual report with nested tables... manage your expectations, and mine.
Try it: PDF to Excel — upload a statement and see what the coordinate heuristic makes of it.
What's the gnarliest PDF table you've ever had to extract? Bonus points if it involved merged cells.
Top comments (0)