Finding tables in PDFs without a server
There are no tables in a PDF. There are glyphs at coordinates, and there are lines drawn on a page, and a human eye assembles those into a table. Everything below is the work of doing that assembly from the other side, in a browser tab, with no server involved.
The pipeline has three passes and a lot of tolerances. The tolerances are the interesting part, because every one of them is a decision about which failure you would rather have.
The pipeline
1. Open the PDF (with a password retry)
2. Per page: extract text items AND drawing operations
3. Merge text fragments into word-level chunks
4. LATTICE pass per page: drawing ops -> line segments -> grid -> cells -> CSV
5. STREAM pass, only for pages where lattice found no grid:
classify rows -> group regions -> detect columns -> CSV
6. Form-versus-table classifier drops the non-tabular regions
7. Schema grouping merges same-schema tables, keeps distinct ones apart
8. Output: tables, each with a preview for the picker
Two caps, both deliberate: fifty pages for text extraction, ten for OCR. A browser tab is not a batch job, and a hundred-page scanned document is a case where the honest answer is that this is the wrong tool.
Pass one: lattice, or reading the lines somebody drew
An invoice, a lab report, a form: these usually have ruled borders, and a ruled border is a drawing instruction in the content stream. If you can recover the lines, you can recover the cells, and cell boundaries beat any amount of guessing from text positions.
Getting the lines means walking pdf.js's operator list and tracking the current transformation matrix, because coordinates in a content stream are in whatever space the enclosing transforms put them in. Save, restore and transform all have to be honored:
function multiplyCTM(ctm, m) {
const [a, b, c, d, e, f] = m;
return {
a: a * ctm.a + b * ctm.c,
b: a * ctm.b + b * ctm.d,
c: c * ctm.a + d * ctm.c,
d: c * ctm.b + d * ctm.d,
e: e * ctm.a + f * ctm.c + ctm.e,
f: e * ctm.b + f * ctm.d + ctm.f,
};
}
Skipping the matrix and reading raw coordinates works on maybe eighty percent of documents and fails on anything that scales or translates its table, which is common in generated reports. It is also the kind of bug where the output is a plausible table with the wrong cell boundaries, which is worse than no output.
The extractor accepts two operator schemas, because pdf.js changed. Older versions emit
individual moveTo,
lineTo and
rectangle operations;
version 4 and later batch them into a single
constructPath. Both are
handled and unknown operations are ignored silently, which is the right default when your
input is somebody else's version of a library.
Then three constants decide what counts as a line:
/** Snap tolerance for clustering nearby parallel lines (PDF points). */
const LINE_SNAP_TOL = 3;
/** Tolerance for treating a line as horizontal/vertical. */
const ORIENTATION_TOL = 1.5;
/** A line must be at least this long to count (filters noise / decorative dots). */
const MIN_LINE_LENGTH = 8;
A PDF point is 1/72 of an inch, so three points is about a millimeter. That is the distance within which two nearly-parallel rules are considered the same grid line, and it has to be non-zero because a table border is frequently drawn as several segments that do not share an exact coordinate. The orientation tolerance of 1.5 points exists because lines are often a hair off axis after a transform. The minimum length filters decorative dots and the tiny strokes that some generators leave behind.
When there are no drawing operations at all, or the lines do not form a recognizable grid, lattice returns an empty array and the page falls through to stream mode. Returning nothing is a feature here: a partial grid is worse than no grid, because it produces a table that looks right and has cells in the wrong places.
Pass two: stream, or inferring columns from alignment
Most business PDFs have no rules. A bank statement is text in columns, held together by nothing but consistent x positions. Stream mode reconstructs the table from that.
Merging fragments
pdf.js frequently splits one cell into several text items, sometimes per word, sometimes per kerning pair. Before anything else, items on the same line separated by small horizontal gaps are merged into word-level chunks. Get this wrong and column detection is working with the wrong primitives.
Classifying rows
Not every line on the page is a table row. Headings, paragraphs and footnotes have to be excluded, and the naive rule of "a row with more than N chunks is a table row" fails on a very common layout: a header with five columns above data with four, because the last column header spans two data columns.
So the classifier is bimodal. It finds the top two modes of the chunk-count distribution and accepts rows within a tolerance of either. That one change fixed a category of report where the header row was being classified as prose and thrown away.
Grouping into regions
Consecutive table-like rows become a region. Regions split on three things: a run of more than three non-table rows, a column-position discontinuity, and a page boundary. The comment in the source states the trade explicitly:
// Splitting aggressively (rather than greedily merging) is the right
// default: same-schema regions get rejoined later by schema grouping
// (cross-page lab tables, multi-section reports), but mis-merged
// regions never get re-split. False splits cost picker entries; false
// merges corrupt CSV.
This is the single most useful principle in the whole extractor. Make the reversible mistake, never the irreversible one. A false split shows the user two entries in a picker and they choose. A false merge stacks an invoice's line items on top of its summary table and produces a CSV with garbage in half the rows.
Detecting columns
The row with the most chunks becomes the seed, usually the header. Its chunk x positions define column anchors. Other rows contribute anchors only where their chunks fall outside the existing ones, which catches columns the seed missed. Boundaries sit midway between adjacent anchors.
What the detector deliberately does not do is grow a column to fit data:
// We deliberately do NOT grow regions to "fit" data chunks: bank
// statements and invoices have right-aligned numeric columns where chunk
// x-positions vary widely, and growing a column to accommodate them would
// absorb its left-hand neighbor.
Right-aligned numbers are the reason. In an amount column, 9.99 and 12,480.00 start at very different x positions, and a detector that widens the column to include both ends up swallowing the description column next to it. Chunks that genuinely span two columns are handled later by a splitting pass, which is a more surgical fix than moving a boundary.
Pass three: OCR, and the gate that took two tries
A scanned page has no text layer at all. Tesseract compiled to WebAssembly handles those, at a real cost: it is slow, it is noisier than native extraction, and it is English only in our configuration. So the gate matters more than the OCR does.
We got the gate wrong the first time, and the comment records it:
/**
* Native text items below which a page counts as having no text at all.
*
* This used to be 20, which is a page with a title, a date and a short
* paragraph on it — a perfectly readable text page. Those pages went to OCR,
* and OCR's noisier output replaced text that had been extracted correctly.
* A genuinely scanned page yields zero native items (or one or two stray
* marks from a stamp or a page number), so the gate belongs down here.
*/
export const OCR_NATIVE_ITEM_FLOOR = 3;
Twenty sounded conservative and was actively harmful. A cover page with a logo, a title and a date has about a dozen text items, and it was being rasterized and re-recognized, replacing perfect text with approximate text. Three is the number of stray marks a genuinely scanned page produces.
Once a page is a candidate, it is rendered to a canvas at 300 DPI, capped at 4096 pixels on the longest edge so mobile canvas limits are not exceeded. Then the bitmap is preprocessed, grayscale plus Otsu binarization, which finds the threshold that maximizes between-class variance:
let sumB = 0, wB = 0, maxVar = 0, threshold = 127;
for (let t = 0; t < 256; t++) {
wB += hist[t];
if (wB === 0) continue;
const wF = n - wB;
if (wF === 0) break;
sumB += t * hist[t];
const mB = sumB / wB;
const mF = (sum - sumB) / wF;
const v = wB * wF * (mB - mF) * (mB - mF);
if (v > maxVar) { maxVar = v; threshold = t; }
}
Otsu is from 1979 and it is still the right answer for this. A fixed threshold of 128 fails on a gray-background scan or a faint photocopy; Otsu adapts per page and costs one histogram pass.
Two details in the geometry that matter more than they look. Words with a confidence below 30 are dropped, because a low-confidence word in a numeric column is worse than a gap. And the y coordinate recorded for a word is its vertical center, not the top of its bounding box:
* - `y` is the word's vertical CENTER. Word bounding-box tops jitter with
* ascenders ("h") vs. x-height words ("acre"); centers stay stable, so
* row clustering doesn't split lines.
That is a two-line fix for a class of bug that looks like the OCR being bad. A line containing both "height" and "acre" has bounding box tops several pixels apart, and row clustering on tops splits it into two rows. Centers do not move.
There is also a 45-second per-page timeout, because tesseract on a pathological page can run essentially forever and the user is sitting in front of a spinner.
Putting the pieces back together
After three passes you have many small tables: one per region per page. A five-page lab report is five tables that are really one, and an invoice is two tables that are really two. Telling those apart is schema grouping.
The signature is the column count plus the normalized text of the header row, and the match is exact:
* Schema match is strict: same column count AND identical normalized header
* text. False positives (merging two unrelated tables) are worse than false
* negatives (showing two near-identical schemas as separate picker entries),
* so we deliberately avoid fuzzy matching.
Same principle as region splitting, applied one level up.
A header row is defined as text-only by convention, so if any cell in the first row parses as a number, that row is data rather than a header. Those fragments are marked headerless and can be adopted into a headered group with the same column count, which is exactly the per-page lab-report case where the canonical header lived on page one.
Then page furniture. Running headers, address boxes and info panels repeat verbatim across pages, and they arrive as rows inside a detected table. A candidate is an all-text row with at least two cells and no numbers, appearing on several pages. Those rows are dropped, and the last furniture row before a run of data becomes that run's header, which handles the common layout where the repeated running header is the column header.
Finally, entirely empty columns are dropped, and form-like layouts, meaning label and value pairs rather than a real grid, are classified out of the table results altogether.
What is still wrong
The limitations comment in the source is not marketing copy, so here it is unedited:
* Limitations:
* - Coarse CTM tracking (translates, scales, rotations all supported, but
* non-portrait rotations may produce unexpected cell layouts).
* - Merged cells / nested tables not fully supported.
* - Max 50 pages for text extraction, 10 for OCR (English-only).
I would add one more from our own issue list: OCR reference-interval columns often come back empty on lab reports, because the values sit in a narrow column with small type and the confidence filter eats them. That is a real gap and it is not fixed.
The thing I would most want a reader to take from this is the trade that appears twice, at two different levels of the pipeline: prefer the reversible error. Split aggressively and rejoin on evidence. Merge only on exact agreement. When you cannot be sure, hand the user a picker rather than a decision. Every place we violated that rule produced a bug report where the output looked correct and was not, and those are the expensive ones.