
Every tutorial on extracting tables from PDFs uses the same example: a clean, machine-generated invoice where pdftotext -layout returns tidy columns and everyone goes home happy.
Then you point the same script at a document somebody actually made, in InDesign or Illustrator, for print — and it returns rows of blanks. Or worse, rows that look correct and aren’t.
I spent a few weeks parsing manufacturer brochures to build a comparison tool, and hit five distinct failure modes. None of them is exotic. All of them are silent, which is the problem: the parse doesn’t crash, it just quietly gives you wrong data.
Here’s each one, how to spot it, and what to do.
1. The ticks are an invisible symbol font
You run pdftotext -layout, print a feature row, and get:
Rear Parking Camera
No ticks, no crosses, nothing. So you conclude the table is image-based and start reaching for OCR.
It isn’t. The ticks are there — they’re Wingdings characters in the Unicode private use area, and your terminal renders them as nothing at all.
text = raw.replace("\uf0fc", "Y").replace("\uf0fb", "N")
\uf0fc is a check mark. \uf0fb is a cross. Substitute them before you look at anything, or every row will appear empty and you’ll waste an afternoon.
How to spot it: dump the raw bytes of a row you know has data.
print([hex(ord(c)) for c in row if ord(c) > 0x2000])
If you see 0xf0fc or anything in the 0xf000–0xf0ff range, it’s a symbol font, not an empty table.
2. The tick is the digit 9
This one is my favourite, because the output is completely plausible.
Some print designers set tick marks in Wingdings by typing a character that maps to a tick in that font. In Wingdings, the glyph for a check mark sits at the code point for ü. In other documents, it’s 9. The PDF stores the character; only the font makes it a tick.
So a row extracts as:
Alloy Wheels — — 9 9 — 9 9
That is not corrupt data. It’s a perfectly good boolean row: no, no, yes, yes, no, yes, yes. The em dash is the absence marker.
How to spot it: find two rows you know are mutually exclusive — a document will often have “4.2-inch display” and “7-inch display” where every column has exactly one. If their `9`s partition the columns cleanly, you’ve found your tick character.
a = parse_row("4.2-inch") # [1,1,0,0,0,1,0]
b = parse_row("7-inch") # [0,0,1,1,1,0,1]
assert all(x + y == 1 for x, y in zip(a, b)) # every column has exactly one
That assertion is worth more than any amount of eyeballing. If it fails, your column mapping is wrong and everything downstream is wrong with it.
3. The columns aren’t where the header says they are
pdftotext -layout preserves horizontal position, so the obvious approach is to find the header’s x-offsets and slice every row at those positions.
This works until a document has a long label that wraps, or a column whose x-offset drifts by a few points partway down the page. Then your slices land mid-value and you get blanks in the middle of a populated row.
Don’t slice at fixed offsets. Split on runs of whitespace and read values in document order:
import re
values = re.split(r"\s{2,}", row.strip())
Two or more spaces is a reliable column separator in -layout output; one space is a word break inside a label.
And handle wrapped labels. If a row has fewer values than you have columns, it’s probably continued on the next line rather than genuinely sparse. Join first, parse second.
4. Two tables sit side by side on one page
Print layouts love a two-column page. pdftotext reads in document order, which means it can interleave the left table’s row 1 with the right table’s row 1, producing a single line containing two unrelated records.
You’ll notice because your row will have roughly twice the expected number of values and the extra ones won’t make sense.
The fix is to crop before you parse. pdftotext accepts a bounding box:
pdftotext -layout -f 15 -l 15 -x 0 -y 0 -W 300 -H 800 doc.pdf left.txt
pdftotext -layout -f 15 -l 15 -x 300 -y 0 -W 300 -H 800 doc.pdf right.txt
Page 15, left half and right half, parsed separately. Find the split point by rendering the page to an image once and looking at it.
5. It’s actually an image
Some documents really are image-only. The test takes one second:
pdftotext -layout doc.pdf - | wc -l
Zero lines means there’s no text layer at all. I hit this on a 37MB brochure that looked identical to a dozen text-based ones.
When that happens, render and read:
pdftoppm -r 300 -png -f 8 -l 8 doc.pdf page8
300 dpi is the sweet spot — 200 loses thin strokes on tick marks, 400 doubles the file size for no gain. Crop the result into horizontal strips of a few rows each; a full page at 300 dpi is too much to read reliably in one go, whether by OCR or by eye.
Slower than parsing, yes. But it’s also how you should double-check any parse you’re unsure of. I’ve caught column-mapping errors this way that no assertion would have found.
The rule underneath all five
A blank means “no data”, not “no”.
This is the one that will actually hurt you. Every failure above produces blanks, and the tempting move is to treat a blank cell as a false. Do that and you’ll confidently publish that a product lacks a feature it has.
In my case that would have meant telling someone a car had no reversing camera when it did. So the rule I settled on: a blank is unknown, never false, and unknown gets recorded as such and shown to the reader as “we don’t know” rather than silently rendered as absent.
It makes the output less tidy. It also makes it true.
Worth building a checklist
If you’re doing this at any scale, run these four checks on every document before trusting a single row:
- Dump the code points of a row you know is populated. Symbol font?
- Find a complementary pair of rows and assert they partition the columns.
- Count values per row against your column count. Mismatch means wrapping
or interleaving, not sparsity.
- Render one page as an image and compare it against your parse by eye.
Four checks, maybe fifteen minutes per document, and they catch essentially everything. I added them after shipping a dataset that credited fourteen features to the wrong trim of a car because one column was blank in the source and I read blank as no.
The project this came from
All of the above came out of building CarMaths, a tool that compares car variants in India by putting a rupee value on every feature that differs between two trims and checking it against the price difference. The equipment data comes from manufacturers’ own brochures — which is where all five of these problems live.
Every valuation it uses is published openly, partly on principle and partly because publishing your working is the fastest way to find out you got something wrong.
If you’re parsing documents somebody designed for print rather than for you, assume all five failure modes are present until you’ve proven otherwise. They usually are.
Work out what your hardware can actually run
Free calculators and directories, sized from real model weights — before you download gigabytes or order a card:
