DEV Community

Cover image for I built a PDF table checker that verifies arithmetic in any PDF without a template
Tae Kim
Tae Kim

Posted on • Originally published at compare-lab.xyz

I built a PDF table checker that verifies arithmetic in any PDF without a template

The problem I kept running into: a financial report where a subtotal on page 8 is wrong, but nobody spots it until the numbers get aggregated into a spreadsheet and something doesn't reconcile.

I wanted a tool that could ingest any PDF with tables and tell me which totals don't add up. The constraint: no template, no column mapping UI, no server. Every byte in the browser.

What it does

Upload a PDF. The tool:

  1. Extracts all text items with their x/y coordinates from each page via pdfjs-dist
  2. Clusters items by y-position into rows (within a 4px snap tolerance)
  3. Detects which rows are "total" or "subtotal" rows by keyword matching
  4. For each total row, identifies numeric columns and sums the data rows above it (back to the previous header or total)
  5. Compares computed sum to stated total — flags differences above a 0.1% tolerance

The hard part: coordinate-based table detection

PDF text items don't know they're in a table. The structure is: a stream of (string, x, y, width) tuples. I have to infer table structure purely from spatial relationships.

The approach:

  • Snap y-coordinates to a 4px grid to handle sub-pixel jitter between lines
  • Cluster x-positions of numeric cells to find "column anchors" — positions that consistently have numbers
  • A column anchor that appears in 2+ data rows becomes a verifiable column
  • For each total row, look up the value at each column anchor and compare to the sum above

This breaks on rotated text, multi-column layouts, and cells that span multiple columns. But it catches the common case — section subtotals in financial reports, grand totals in expense sheets.

Stack

  • Next.js 14, static export on Cloudflare Workers
  • pdfjs-dist for text extraction (CDN worker, no bundling)
  • All detection and arithmetic runs in the browser

Freemium

3 PDFs/month tracked in localStorage. Paid plan unlocks unlimited and CSV export ($9/month).

Live at: https://compare-lab.xyz/pdf-table-checker/

If you work with tabular PDFs regularly and there's a document structure this approach would miss, I'd like to know.

Top comments (8)

Collapse
 
hannune profile image
Tae Kim

Thanks Bhavin — yes, the sub-pixel jitter is the quiet tax you only discover by looking at raw coordinates from pdfjs-dist. My first pass without the snap produced column anchors that fractured into three distinct x-positions for what should be a single column, and the totals never matched anything. The 4px threshold came from inspecting a few actual financial PDFs — most generators stay within 2-3px of their intended grid, so 4px catches the drift without merging genuinely separate columns.

Collapse
 
hannune profile image
Tae Kim

Thanks Frank! pdfjs-dist handles the extraction — it returns a stream of text items, each with string value, x/y position, and width, with no table semantics at all. The column anchor detection does the structural work: I collect all x-positions where numeric strings appear, and any x-cluster that shows up in two or more consecutive rows becomes a verifiable column. Everything else — which rows are totals, what to sum — is keyword matching and arithmetic on top of those anchors.

Collapse
 
bhavin-allinonetools profile image
Bhavin Sheth

Really like the coordinate-based approach. I've worked with PDF parsing before, and the y-position snapping trick solves a lot of those tiny alignment issues that usually break row detection.

Collapse
 
frank_signorini profile image
Frank

This is brilliant! How do you handle the actual text/number extraction from the PDF without relying on

Collapse
 
seanmarkwei profile image
Sean Markwei

Good problem to pick. By the time a reconciliation breaks in a spreadsheet you've usually lost the trail back to page 8, so catching it at the source is worth a lot. Template-free is the hard version of it too.

A few things I've run into with tabular financial PDFs that might break it:

Right-aligned columns vs left-edge anchoring. Numeric columns in reports are nearly always right-aligned, so the left x of a column moves depending on how wide the number is. 1,847,332.10 and 12.40 can land as two different anchors even though they're the same column. If you're clustering on transform[4], try clustering numeric items on x + width instead. The right edge is the stable one. This one is mean because uniform test data hides it, and it shows up on exactly the row you care about: a wide grand total sitting under narrow line items.

pdf.js splits numbers across text items. Depending on kerning and glyph runs you can get 1, then 234 then .56 as three separate items on the same row. Worth merging adjacent same-row items when the horizontal gap is under a space width, before anything tries to parse a number out of them.

Accounting number formats. (1,234.56) means negative. Drop that sign and a correct table gets reported as broken, which is about the worst way for a checker to fail. Same deal with trailing minus (1,234.56-), CR/DR suffixes out of SAP exports, a bare dash or n/a or nil standing in for zero, and EU decimal comma (1.234,56).

Nested subtotals. On a P&L or a balance sheet, summing back to the previous total row will under-count. A grand total covers the section subtotals above it, not just the rows since the last one. You've already got what you need to fix this: indent depth on the label column gives you the hierarchy, so you can sum siblings at the same level only.

Ratio columns. A "% margin" or "% of revenue" column has a total that's a weighted average, so checking it additively throws false positives. Rough heuristic: if the stated total is close to 100, or close to the weighted mean of the rows, skip the column.

Tolerance near zero. A purely relative 0.1% tolerance goes to zero when the total does, so a subtotal that legitimately nets to 0.00 will flag on float noise alone. Integer cents plus an absolute floor (a couple of cents per row summed) covers that, and also covers display rounding, where each row is rounded on its own and the stated total isn't.

Bookmarked. I've got a few statements I'll run through it this week, will come back if I find a layout that trips it.

Collapse
 
wrencalloway profile image
Wren Calloway

@hannune The Korean currency-symbol-as-separate-span case is a good one because it breaks the naive x-gap merge from the other direction — the symbol is a legitimate separate span you want to keep separate, sitting at exactly the tight gap you're using to merge decimal fragments. So the gap threshold alone can't disambiguate. The thing that saved me elsewhere was to stop treating it as pure geometry and add a tiny grammar: a fragment is only a decimal continuation if it looks like one (leading dot or two digits after you've already seen a group separator), and the currency glyph gets classified out before clustering rather than merged in. Slower, but the false merges go away.

On the page-boundary case bypassing your reassembly pass — that's the real trap, because your merge is row-local and the failure is that the row set itself is wrong. I don't think you fix that inside the number layer at all; the window reset is a structural assumption in the row grouper, and no amount of fragment cleanup downstream will undo it. You have to make the total-row search page-aware: carry the "rows since last total" state across the page break instead of resetting at the edge, and treat repeated column headers as continuation markers rather than fresh sections.

Row-count-based tolerance is a smart calibration and honestly better than a flat 0.1% — but be clear-eyed that it's still bounding the error you expect, not the one that actually happens. The unrounded-source case isn't a function of row count; a two-row column can be off by a full cent if both were rounded the same direction. If the source ever gives you unrounded figures anywhere — a footnote, a machine-readable attachment, anything — reconciling against those instead of guessing a threshold is the only thing I've seen actually close it. Absent that, I don't have a clean answer, and I'd rather flag "can't verify rounding basis" than assert a pass.

Collapse
 
wrencalloway profile image
Wren Calloway

The failure mode that'll bite you isn't rotated text or spanning cells — it's that the number you extract and the number a human reads aren't the same value. PDFs routinely display "1,234.50" while the underlying text stream, depending on how the producer laid it out, gives you "1,234" and ".50" as two separate text items at adjacent x-positions, or negatives shown as "(1,234)" or with a trailing minus. Your y-clustering will happily merge or split those, and your sum will be wrong by a decimal shift — the worst kind of wrong, because it's plausible. A subtotal that's off by exactly 100x doesn't look like a parsing bug, it looks like the report is broken, and now you've flagged a false positive on someone's audited financials.

The other one: displayed values are rounded, but the true subtotal is computed on unrounded figures. A column of 33.333, 33.333, 33.334 displays as three 33.33s summing to 99.99, but the stated total is 100.00. Your 0.1% tolerance saves you here for large numbers and burns you for small ones — 0.01 on a 3.00 total is well over 0.1%. Rounding reconciliation is a genuinely hard, domain-specific problem, and "sum the displayed cells" quietly assumes it away.

If you want to know what breaks it, feed it a report where a total row spans a page boundary — the "back to the previous header or total" window resets at the page edge and you'll silently sum half the rows.

Collapse
 
hannune profile image
Tae Kim

Both of those are live issues in the current build — the adjacent-fragment split for decimals (and the parenthetical negative) is the one that burned me most on Korean financial PDFs, where the currency symbol sometimes lands as a separate span. My current workaround is a post-extraction number-reassembly pass that merges fragments within a tight x-gap before clustering rows, but it's heuristic and the page-boundary case you mention would bypass it entirely because the window resets. The rounding reconciliation problem I've not fully solved: the tolerance is calibrated per-column based on the number of rows, so a three-row column gets a tighter absolute threshold than a thirty-row one, which helps but doesn't fix the unrounded-source case.