• Skip to main content
  • Skip to secondary menu
  • Skip to primary sidebar
  • Skip to footer
Best Free Web Resources — local AI tools and hardware guides

Best Free Web Resources

Local AI Tools & Digital Engineering Resources

What can I run?
  • AI Tools
    • Start: Local LLMs
    • Ollama Models & VRAM
    • LM Studio Models
    • GPU Comparison
    • AI Hardware Matrix
    • Local LLM Directory
    • VRAM Calculator
    • Hardware Compare
    • Local Model Recommender
    • API Pricing & ROI
  • Local AI Guides
  • Web Resources
    • Inspiration
    • Photography
    • Games
    • SEO
    • Tutorial
      • Coding
      • WordPress
    • Resume/CV
    • Graphics
      • Logo
    • Freebies

Five Ways a Real-World PDF Breaks pdftotext (And How to Read It Anyway)

September 22, 2026 by Rupesh Kumar

Flat illustration of a PDF feature table with tick marks, dashes and empty dashed cells, for a guide to extracting tables from print-designed PDFs

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:

  1. Dump the code points of a row you know is populated. Symbol font?
  2. Find a complementary pair of rows and assert they partition the columns.
  3. Count values per row against your column count. Mismatch means wrapping

or interleaving, not sparsity.

  1. 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.

⚡ Free Interactive Tools

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:

🚀 Start: Local LLMs Hardware, runner and first model 📊 What Can I Run? Models by VRAM tier, 8GB–128GB 💻 Hardware Matrix 75 laptops & GPUs profiled ⚡ VRAM & KV Calculator Weights, cache and overhead

Filed Under: Coding

About Rupesh Kumar

Rupesh Kumar founded Best Free Web Resources in 2010 and edits the site. He writes about free web resources, web development and running open AI models locally. Learn more: About BFWR & our editorial standards → · Follow on X (@bestfreewebres) →

Primary Sidebar

⚡ FREE INTERACTIVE TOOLS

Local AI Hardware & VRAM Matrix

Benchmark your PC or Mac for local AI, calculate VRAM requirements, and compare cloud API costs vs local hardware ROI.

🚀 New here? Start with Local LLMs → 💻 Hardware Matrix (75 Devices) → 📚 Local LLM Directory (30 Models) → 🎮 AI GPU Comparison → ⚡ VRAM & KV Cache Calculator → 💰 API Pricing & Break-Even → 🍎 Mac VRAM Unlocker (90% Boost) →
📦 Ollama 🖥️ LM Studio ⚙️ llama.cpp 🚦 vLLM 💬 Open WebUI 🎨 ComfyUI 🔒 Self-Hosted 📊 VRAM Tiers 🎯 Recommender ⚖️ Comparisons

Search

  • Facebook
  • Instagram
  • RSS
  • Twitter
  • YouTube

Footer

Latest Guides

  • Two 16GB Graphics Cards Can Be 3.3x Apart for Local AI
  • AI Upscale Models in 2026: Which Still Ship, and Which You Cannot Sell
  • ControlNet Models in 2026: Which Ones Still Ship, and What They Weigh
  • Stable Diffusion LoRA: Matching the Base Model, Weights and Stacking
  • Stable Diffusion Image Editors in 2026: Which Ones Are Still Maintained
  • ComfyUI Templates in 2026: 660 Built In, and Half Need the Cloud

AI Tools & Calculators

  • What can I run?
  • VRAM Calculator
  • GGUF Size Calculator
  • Fine-tuning VRAM
  • LoRA Training VRAM
  • Image Generation VRAM
  • Mac VRAM Optimizer

Browse & Compare

  • Models Directory (30 models)
  • Hardware Matrix (75 devices)
  • Image Models
  • Model Licences
  • Comparisons
  • Model Recommender
  • API Pricing & ROI

Guides & Resources

  • Technology
  • Coding
  • Tutorials
  • Freebies
  • Graphics

Copyright © 2026 · Best Free Web Resources · All Rights Reserved.  |  About · Privacy Policy · Contact · AI Tools Hub