# My AI said the PDF was empty. The PDF was not empty.

> Source: <https://dev.to/andrewavery7/my-ai-said-the-pdf-was-empty-the-pdf-was-not-empty-1b1l>
> Published: 2026-08-19 14:51:30+00:00

I asked Claude Code to pull the key dates out of a PDF I had saved from a

webpage. It came back immediately:

The document appears to be empty — it contains no extractable text.

Which was strange, because I had the PDF open on my other monitor and it was

four and a half megabytes of perfectly legible text.

The interesting part is not that it was wrong. The interesting part is that

nothing had failed. Every component in that chain did exactly what it was

designed to do, reported success, and stacking those successes together

produced a lie I believed.

I was preprocessing documents with [markitdown](https://github.com/microsoft/markitdown),

Microsoft's file-to-markdown converter, so I ran it by hand:

``` bash
$ markitdown screenshot.pdf -o out.md
$ echo $?
0
$ wc -c out.md
0 out.md
```

Exit code 0. Zero-byte output file. No warning, no stderr, nothing on the

console at all.

My integration did what integrations do: checked the return code, saw success,

cached the result, and handed the model a path to a file with nothing in it. The

model read the file, found nothing in it, and told me the document was empty.

From its position that was a reasonable conclusion. It had been given an empty

file and told the conversion worked.

My first instinct was to file an issue. I am glad I did not, because markitdown

is behaving correctly and I would have been publicly wrong.

The PDF was a full-page browser screenshot exported to PDF. It contains raster

images and **no text layer whatsoever**. `pdfminer`

reports 0 characters, and so

does PyMuPDF when you ask it. markitdown's PDF backend extracts embedded text

and does not OCR — that is a documented design decision, not an oversight.

So there was genuinely nothing to find. And *finding nothing is not an error.*

A converter that exited non-zero every time a document happened to be empty

would be wrong in a much more annoying way.

The bug is somewhere else entirely, and it is worth naming precisely:

The bug is in every integration that treats exit code as evidence of yield.

Exit code answers "did the process complete?" I was reading it as an answer to

"did we get the text?" Those are different questions, and for a document

converter meeting a scanned page they have different answers. Mine was one of

those integrations. Probably yours is too — `pdftotext`

, `pandoc`

and most

extraction tooling have the same shape, because they should.

That reframing is the whole story. Everything below is consequences.

**Never measure success by exit code. Measure it by yield.**

Simple to say. The trouble starts immediately, because "measure the yield" needs

a threshold, and thresholds are where honest engineering goes to become

arbitrary. Anyone can write `if len(text) == 0: fail`

. That catches the

screenshot. It does not catch the case that actually cost me time.

A course completion certificate. One page, a decorative graphic, and a title

line rendered as real text. It converts to this:

```
Certificate of Completion
```

Thirty-nine characters. Not zero. It sails through an emptiness check, gets

cached as a successful conversion, and the model dutifully reports that your

certificate says "Certificate of Completion" and nothing else — which is, again,

technically what it was given.

That is the shape of the real problem. The fully-empty case is easy and any

check catches it. The expensive failures are the near-misses: a certificate,

a slide deck exported as page images with a footer on every slide, a contract

scanned at an angle with a header that happened to OCR at some point. They all

return *some* characters.

So the question becomes: how do you tell "extraction failed" from "this document

is legitimately short"?

The obvious move is a minimum size — reject anything under, say, 500 bytes. It

does not work, and the reason it does not work is worth being precise about:

**raw byte count conflates document length with extraction quality.**

800 bytes is a complete and correct conversion of a one-page memo. 800 bytes

from a 200-page report is a catastrophic extraction failure. The same number

means opposite things and the measure cannot distinguish them.

What you want is **density**, not volume. Characters per page normalises

document length away and leaves only the question you actually care about: on

each page, did we recover a page's worth of text?

I ran four real documents — a deliberately mixed set, including the near-miss

that started this:

| Document | Pages | Chars | Chars/page |
|---|---|---|---|
| Webpage screenshot saved as PDF | 1 | 0 | 0 |
| Course certificate (graphic + title) | 1 | 39 | 39 |
| Two-page text document | 2 | 1,864 | 932 |
| Twenty-page slide deck | 20 | 13,289 | 664 |

Two populations. No overlap. An order of magnitude between them.

That gap is the finding. It is not a subtle statistical separation requiring a

tuned classifier — a text extractor meeting a page of text produces hundreds of

characters per page, and a text extractor meeting a picture produces tens or

zero. There is nothing in between, because there is no such thing as a document

that is 40% made of text.

A threshold of **100 characters per page** sits in that gap with roughly a 6×

margin on both sides. The certificate is 2.5× below it; the sparsest real

document is 6.6× above it. Small variations in document style — bigger fonts,

more whitespace, a title slide — cannot cross a gap that wide.

That margin is the entire justification for the number. I would not defend 100

as optimal. I would defend it as *comfortably inside a gap where nothing lives*,

which is a much better property for a threshold than being finely tuned.

Every threshold gets some cases wrong. What you get to choose is which way.

**A false positive** — a genuinely sparse PDF gets flagged as image-based — means

the model reads the original document with vision instead. That costs more

tokens. It loses nothing. The user gets a correct answer at a higher price.

**A false negative** — an empty conversion is presented as real — means the model

confidently reports an empty document. The content is lost entirely and *the
user has no signal anything went wrong.* That is the failure I started with.

Those costs are not remotely symmetric, so the threshold is deliberately set to

prefer the first. When it is wrong, it is wrong in the direction that costs

money instead of the direction that costs truth.

One more asymmetry, discovered by getting it wrong: **the density test has to be
PDF-only.** Applying it to every format looks consistent and is a mistake. A

Once you start looking for "success that isn't", it turns out to be a genre.

**Caching a bad result is worse than producing one.** My first version cached by

modification time. A zero-byte conversion was therefore served for every future

reference to that document — permanently, with no retry, even after I had fixed

the underlying cause. A transient failure had been promoted to a permanent one

by the cache. Conversions are now re-graded before reuse, unusable results are

deleted rather than stored, and zero-byte artifacts from older versions get

swept on the next run so an upgrade heals the cache without anyone intervening.

**Silence is a failure mode.** The hook must never block a prompt, so unexpected

errors exit 0 quietly. That is correct for almost everything and catastrophic

for one case: if markitdown is not installed at all, a silent no-op is

indistinguishable from "this document is empty" — the exact failure the whole

project exists to prevent. Missing dependencies are now the one error reported

loudly, with the install command.

**The bug that produced no output at all.** On Windows, PowerShell 5.1 prepends

a UTF-8 BOM when piping to a native command. `json.load`

raises on the BOM. That

exception hit the never-block-a-prompt handler and was swallowed, so the hook

did nothing, silently, on every prompt, on an entire platform. Two invisible

failure modes composing into a third. The input parsing is BOM-tolerant now, but

the lesson is the one above: an error handler that guarantees silence will

eventually guarantee it for something you needed to hear about.

**A licence is a dependency decision.** PyMuPDF reads some PDFs pdfminer cannot,

so it is used when present — but it is never required. It is dual-licensed

AGPL-3.0/commercial, which does not belong in the dependency set of an MIT

project. Page counting, which the grading needs, uses pdfminer instead, which

markitdown already depends on. The core path adds no dependency and no copyleft.

When a conversion recovers real content, the model gets a pointer rather than

the text:

```
[markitdown] /path/report.pdf was converted to markdown at
~/.claude/markitdown-cache/report-a1b2c3d4.md (14973 bytes, 304 lines,
14472 chars, 20 page(s)). If this document's content is needed, Read or
Grep the .md file (not the original).
```

The pointer matters more than it first looks. Prompts mention documents

speculatively — "compare these three reports" may only genuinely need one of

them. Loading all three into context costs those tokens on every subsequent turn

of the conversation, used or not. A pointer costs about 400 characters and is

paid once. The model reads or greps the file, with offset and limit for big

ones, only if the content turns out to matter.

And when extraction recovers nothing, no file is written, nothing is cached, and

the model is told the truth:

```
[markitdown] NO USABLE TEXT extracted from /path/screenshot.pdf (no text
extracted). This is an image-based/scanned PDF -- text extraction cannot
see into it and no .md was written. Read the ORIGINAL file natively with
the Read tool (use the `pages` parameter for long PDFs); Claude's vision
can read it. Do NOT report the document as empty.
```

That last line is there because without it the model does exactly that.

None of this is really about PDFs.

Any pipeline that hands one tool's output to another has this hazard the moment

the first tool can succeed at doing nothing. Exit codes are a claim about

process completion. They were never a claim about yield, and we have all been

reading them as one because for most tools, most of the time, the two happen to

coincide.

The habit worth taking from it is small and cheap: **after any extraction step,
measure what came back and decide whether it is plausible for the input.** Not

And when it is empty, say so. A downstream model handed an empty file will not

wonder whether something went wrong. It will tell your user their document is

blank, in the same assured voice it uses when it is right.

*The hook I built out of this is MIT and runs on Windows, macOS and Linux:
claude-markitdown-hook.
The measurements behind the threshold, including the fixtures used to calibrate
it, are in docs/DESIGN.md.*
