Chapter 2 of 9 · 18 min read
A gap-free table out of a 150 DPI scan is the alarm
On this page
A scanned page enters an ingestion pipeline as pixels and leaves it as text, and where the pixels are damaged the two available routes disagree in a way that is easy to resolve backwards. One route reads the file: a layout parser takes whatever characters can be established from the page and emits those. The other renders the page to an image and asks a vision model to write out what it sees. Compare the two outputs side by side and one of them will look obviously better — cleaner, better formed, with every field populated — and on a degraded page that appearance is generated rather than earned.
The word doing the work in that sentence is completeness, meaning here that every cell a table's grid calls for has a value in it. Completeness is worth separating from correctness at the outset, because on an undamaged page the two travel together and on a damaged one they come apart, with completeness rising as correctness falls. Whichever route a document takes, what you actually need to know about any extracted value is whether it was read off the page or supplied from somewhere else, and only one of the two routes has any way of telling you.
Completeness is a property of the writer, not of the page
A batch of 3,100 supplier invoices arrives as scanned PDFs. One of them, a two-page account from a bearings distributor, has a coffee-cup ring across the middle of the line-item table and a fax-quality band down the left edge. The layout parser produces this for the affected rows:
MTR-4471 Bearing housing 6205-2RS 12 £ £
MTR-4472 Bearing housing 6206-2RS 8 £ 91.20 £ 729.60
MTR-4488 Seal kit, nitrile 24 £ £The vision model, given the same page as a rendered image, produces this:
| Part | Description | Qty | Unit price | Line total |
| -------- | ------------------------- | --- | ---------- | ---------- |
| MTR-4471 | Bearing housing 6205-2RS | 12 | £74.40 | £892.80 |
| MTR-4472 | Bearing housing 6206-2RS | 8 | £91.20 | £729.60 |
| MTR-4488 | Seal kit, nitrile | 24 | £18.25 | £438.00 |Every cell populated, the arithmetic internally consistent — 12 × 74.40 = 892.80, 24 × 18.25 = 438.00 — and a clean pipe table that any downstream consumer will accept without complaint. The printed unit price on the paper invoice is £74.04, and the printed line total is £888.48. The model did not read those digits, because at the resolution it received them the digits were not there to read. It produced a unit price consistent with what bearing housings cost, and then produced a line total consistent with that unit price, and the internal consistency of the row is a product of the same generative process that produced the error rather than evidence against it.
Check the middle row before going any further, because it is the row the arithmetic argument turns on. MTR-4472 is the one line the parser read in full, so its unit price and line total are copied characters rather than decoded ones — and multiplying its quantity by its unit price gives 8 × 91.20 = 729.60, which is exactly the line total printed beside it. The row that was read closes to the penny, and so do the two rows that were invented. A rule that multiplies quantity by unit price and compares the product to the line total returns the same verdict on all three. It is constant across precisely the distinction it is being asked to make, which means a passing result carries no information about whether anything on the page was read.
The parser's blank cells are the pixel evidence, reported. Nothing in the content stream resolved to characters in that region, so nothing was emitted, and the gap in the output corresponds one-to-one with a gap in what could be established. That is the most useful thing any component in an ingestion pipeline can do, and it is exactly what gets discarded when the vision output is preferred because it has no holes.
The reason the vision path never produces holes is structural. Its output is a sampled sequence, and at every position there is a most-likely continuation given the pixels and the prior. When the pixels are informative, the distribution over the next token is sharp and the prior contributes almost nothing. When the pixels are uninformative — a digit reduced to a grey smear — the distribution is set by the prior alone, and the prior for "the unit price cell in an invoice line for a bearing housing, immediately after a row priced at £91.20" is not flat. It is a fairly confident guess. The model has no separate state for "I could not see this", because emitting nothing is not what the decode does; emitting the most plausible thing is what the decode does, always, at every position, regardless of how much of that plausibility came from the image.
Follow that one step further, because the conclusion is stronger than it first looks. For the model to report a blank, the sequence "no value here" would have to be the most probable continuation at that position. That is a statement about what usually appears in an invoice line, and blank unit-price cells are rare in invoices, so the prior pushes away from it exactly where the pixels have stopped pushing at all. The two forces that could produce an admission of ignorance are the two forces that are weakest at the moment ignorance sets in.
That is why a gap-free table out of a degraded scan is diagnostic rather than reassuring. The absence of gaps says the model's prior was strong enough to cover for the pixels everywhere on the page, and it says nothing about whether the pixels agreed.
The pipeline had already thrown the digits away
Before deciding anything about the two outputs, look at what the model was actually shown. The scanned PDF embeds a 300 DPI bilevel TIFF per page. The ingestion pipeline renders pages at a fixed 150 DPI, a setting someone chose two quarters earlier to hold down image token cost. So the model received a downsampled copy of an image that existed at twice that resolution inside the file, and the downsampling happened before any of this became a question.
Work out what that leaves. A 7-point digit has a cap height of roughly 0.70 × 7 pt = 4.9 pt, which is 0.068 inches, and the provider resizes submitted images to fit its own ceiling — the sizing rules covered in the image-inputs topic — so the effective resolution the model sees is not the resolution you rendered at.
CEIL_LONG_EDGE = 1568 # px
CEIL_PIXELS = 1_150_000 # px
CAP_HEIGHT_IN = 4.9 / 72 # 7pt type
def effective(width_in, height_in, dpi):
w, h = width_in * dpi, height_in * dpi
s = min(1.0, CEIL_LONG_EDGE / max(w, h))
s *= min(1.0, (CEIL_PIXELS / (w * s * h * s)) ** 0.5)
return dpi * s, CAP_HEIGHT_IN * dpi * s
print(effective(8.5, 11.0, 150)) # (111.3, 7.6) full page @150
print(effective(8.5, 11.0, 300)) # (111.3, 7.6) full page @300
print(effective(8.5, 3.7, 300)) # (184.5, 12.6) top-third crop @300
print(effective(4.25, 3.7, 400)) # (271.7, 18.5) half-width crop @400Two ceilings are being applied in sequence there, and the second one is where
the reasoning is easy to skip. The first min caps the longer side: if the
rendered page is taller than the long-edge limit, everything shrinks by the
ratio between them. The second min caps the total pixel count, and the target
is an area while the scale factor it returns is a length. Halving each side
quarters the area, so to divide an area by some factor you divide each side by
the square root of that factor — which is exactly what raising the area ratio to
the power of one half is doing. Both scale factors are then multiplied together,
because applying one shrink and then another is the same as applying their
product once.
The two return values follow from that one scale factor. The effective resolution is the resolution you asked for multiplied by whatever survived the two ceilings. The cap height in pixels is that effective resolution multiplied by the cap height in inches — the 0.068 from the line above — so the two numbers are one number expressed twice, and a threshold set on either is the same threshold set on the other.
The first two printed lines are the finding that matters. Rendering the full page at 300 DPI and rendering it at 150 DPI deliver the model the same image, roughly 943 × 1220 pixels, because both exceed the provider's ceiling and both get resized down to it. The 150 DPI render is not cheaper — it costs the same ~1,540 image tokens — it simply arrives at the ceiling having already destroyed half the linear detail on the way. Two quarters of a cost control that saved nothing and cost accuracy on every small-print page in the corpus.
It also means that "render higher" is only the first half of the fix. A full US Letter page submitted as one image tops out at about 111 effective DPI whatever you do, which puts 7-point digits at seven or eight pixels of cap height, below the twenty-odd pixels that character recognition conventionally wants. To get small print above that floor you have to reduce the area covered by a single image, which means cropping to the region of interest and sending that region at native resolution. The last two lines of the arithmetic show the payoff: the table region alone, sent as its own image, arrives at 184 effective DPI; a half-width crop at 400 DPI arrives at 272 and puts the same digits at 18.5 pixels.
| What is sent | Render | After provider resize | Effective DPI | 7pt cap height | Image tokens |
|---|---|---|---|---|---|
| Whole page | 150 DPI | 943 × 1220 | 111 | 7.6 px | ~1,540 |
| Whole page | 300 DPI | 943 × 1220 | 111 | 7.6 px | ~1,540 |
| Table region | 300 DPI | 1568 × 676 | 184 | 12.6 px | ~1,410 |
| Half-width crop | 400 DPI | 1155 × 996 | 272 | 18.5 px | ~1,530 |
The first two rows of that table are identical in every column, and that identity is the whole argument for reading it. Two different render settings, one of them twice the other, produce the same pixels, the same effective resolution, the same cap height and the same token bill. Nothing distinguishes them downstream, so nothing downstream could ever have reported that the choice between them mattered — the render setting was invisible to every measurement the pipeline took, which is why it survived two quarters unexamined.
Read the last three columns as one column and the table gets simpler still. Effective DPI and cap height move together by construction, since one is the other times a fixed number of inches, so they carry a single fact between them. The token column is the one that comes apart from them: it stays flat across a threefold spread in effective resolution, because every row is being resized to roughly the same pixel budget. That decoupling is the actionable finding. What you pay for is pixels submitted, and what you get is pixels per printed character, and the second is set by how much page area you decided to put into one image.
Which validator fires, and on which output
The invoice carries a printed grand total of £4,318.72, which sits outside the damaged band and is legible in both paths. The vision model's original table sums to £4,323.04. A totals check catches this particular fabrication, and it is worth installing, but it is worth being precise about how much of the space it covers.
The size of that discrepancy is not arbitrary, and working out where it comes from is what turns a totals check from an alarm into a diagnosis. The fabricated unit price for MTR-4471 exceeds the printed one by thirty-six pence. That row carries a quantity of 12, so the fabricated line total exceeds the printed line total by twelve times thirty-six pence, which is £4.32 — and the printed line total is exactly the printed unit price times the same quantity, which is why the two routes to £4.32 land on the same figure. The gap between the summed column and the printed grand total is £4.32 as well, with nothing left over. That zero residual is doing work: it says one fabricated cell accounts for the entire discrepancy, so every other cell in the column matched the paper. Had the residual been anything other than zero, a second fabrication would have been sitting somewhere in the column, and the totals check would have been reporting the sum of two errors as though it were one.
| Validator | Fires on the parser output | Fires on the vision output | Is it catching the failure? |
|---|---|---|---|
| Empty required cell | Yes, 4 cells | No | No — it flags the honest output |
| Row arithmetic (qty × unit = line) | Cannot evaluate, operands missing | No, rows are self-consistent | No |
| Column sum against printed total | Cannot evaluate | Yes, off by £4.32 | Yes, for this page |
| Emitted cell count against detected grid | No | No, grid matches | No |
| Effective DPI floor on the region | Yes | Yes | Yes, and before the model runs |
The count in the first row is checkable against the specimen above rather than taken on trust: MTR-4471 has a currency symbol with no digits after it in both its unit price and its line total, MTR-4488 has the same two, and that is the four cells. Reading down that row is also the clearest statement of the inversion in this chapter. The empty-cell rule is not merely silent on the failure; it fires on the output that told the truth and stays quiet on the output that did not, so acting on it moves you from the correct answer to the wrong one every time.
Three of the five rules either cannot see the vision failure or actively flag the parser instead, and the one arithmetic check that does catch it only works because the fabricated unit price happened to break a relationship the page states elsewhere. It is easy to fabricate a value that breaks nothing: a description string, a date, a part number, or a figure in a table with no totals row. The check that generalises is the last one, because it is a property of the input rather than of the output, and it fires before you have spent anything on the call.
That last row is also the only one whose verdict changes with the thing it is meant to detect. Every output-side rule above it is evaluated on text that already exists, and text that was generated reads the same as text that was read — that is the entire point of the failure. A resolution floor is evaluated on the image, before any text exists at all, and the image either carries enough pixels per character or it does not. A check upstream of the failure can discriminate; a check downstream of it is inspecting the evidence the failure has already replaced.
Why cross-filling is worse than either output alone
The proposal that follows naturally from these two outputs is to run both paths and use the vision model's values to fill the parser's blanks, keeping the parser's characters where it produced them. It sounds conservative — the parser stays authoritative, the model only contributes where the parser had nothing — and it is the single worst combination available.
The parser's blanks are not randomly distributed. They are precisely the cells where the pixels failed, which are precisely the cells where the vision model's output is least constrained by the image and most determined by its prior. So the merge takes the model's values from exactly the region where the model is weakest, and it takes them silently, because after the merge the two sources are indistinguishable in the output.
A hand-checked sample makes the size of the effect concrete. Over 180 scanned invoices with visible degradation, 6,140 line-item cells were transcribed and checked against the paper. On the 5,900 cells that were cleanly legible, the vision model matched the printed value 99.2% of the time. On the 240 cells where the parser emitted nothing, it matched 61% of the time. Aggregate accuracy over all 6,140 cells comes out at 97.7%, a number that would pass any review, and the conditional accuracy on the only cells the merge proposal actually uses is 61%. Reporting the aggregate is not wrong arithmetic; it just answers a question nobody asked, because the merge never consults the model on the 5,900 easy cells.
Those four figures close on each other exactly, and reconstructing the aggregate from the other three is the fastest way to see why it is empty. The two groups partition the sample with nothing left over: the legible cells and the cells the parser left blank add up to the full 6,140. So the aggregate is just the two conditional rates weighted by how many cells each group holds, and carrying that weighting out reproduces 97.7% to the last digit printed. It is not an independent measurement that happens to be reassuring. It is the other three numbers rearranged, and since the easy group holds more than nineteen of every twenty cells, the aggregate is very nearly the easy group's rate wearing the whole sample's name.
There is a further cost that does not show up in either number. Before the merge, the pipeline held a page with four cells it admitted it could not read, which is a page you can route to re-scan, to a higher-resolution crop, or to a human. After the merge, it holds a complete invoice. Nothing downstream will ever ask about it again.
Blanks in both paths, and scans that cannot be re-rendered
Rejecting on incompleteness is not the rule. A page whose table genuinely has empty cells — a column that does not apply to some rows, a subtotal line with no unit price — will produce blanks in both paths, and a policy that treats blanks as a defect will reject correct extractions of ordinary documents. The signal in this chapter is narrower: blanks from one path against a full grid from the other, on a page whose input resolution is below what the print size needs. Both halves are required.
Nor does higher resolution always exist to be had. When the source PDF embeds a 150 DPI scan and no better copy is available, no render setting recovers what the scanner did not capture, and the honest outcomes are to request a better copy from the supplier or to index the page with its unreadable fields marked as unreadable. Chapter 9 takes up what that policy costs in coverage and how to make it defensible.
The resolution floor carries a condition too, and it is worth stating rather than leaving implied. A floor computed from cap height assumes you know the print size on the page, and on a scan of unknown provenance you may not — the same physical page can arrive as any number of pixel dimensions, and the point size only becomes recoverable once you can relate pixels to inches. Where that relation is unavailable, the floor degrades into a check on submitted pixels per page, which still catches a page rendered far too small and no longer tells you anything about 7-point type in particular.
What makes a gap-free table out of a degraded scan alarming is that the completeness is evidence about the writer and not about the page. A decoder emits its most plausible continuation at every position, so it fills a cell the pixels could not support in exactly the same motion it uses to copy one they could, and the two are indistinguishable afterwards by any inspection of the text. The parser's blank is the opposite kind of artefact: it is the only place in either output where the extraction admits what it could not establish, and it is the thing every instinct on first reading says to get rid of. Preferring the complete output, filling the blanks from it, or validating on emptiness all do the same thing, which is to destroy the record of where the page stopped carrying information — and the only checks that survive that reasoning are the ones evaluated on the image before any text exists.
Deciding which cells were read and which were guessed required a hand-check against paper, because the emitted markdown carries no per-cell evidence — no coordinates, no confidence, no state for "not legible". That absence is not a property of vision models. It is a property of the output format that was asked for, and the next chapter is about choosing that format deliberately, since the format settles in advance which defects can even be represented.
VLM-based parsing — read the current version at https://daviddominguez.dev/learn/en/vlm-based-parsing/a-gap-free-table-out-of-a-150-dpi-scan-is-the-alarm