Skip to content

A PDF read back as a document rather than as text - #2

Merged
tannevaled merged 2 commits into
mainfrom
a-pdf-read-back-as-a-document
Oct 12, 2026
Merged

tannevaled merged 2 commits into
mainfrom
a-pdf-read-back-as-a-document

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

A PDF page has no headings, no paragraphs and no lists. It has instructions for
drawing glyphs at places, and everything a reader sees above that is an
inference. This turns those inferences into the
richdoc model — which gets Markdown,
reST and HTML out of a PDF through code the fleet already has.

main was created by GitHub (ghnew), so no unreviewed commit of ours is in
the history.

What a heading is

Text set larger than the body, where the body size is the one the document
draws the most characters at, over every page. That much is PyMuPDF4LLM's
IdentifyHeaders rule.

⛔ But larger is not enough, and taking it as enough was a defect this
reported on four real documents out of six.
A 9-page report set in 10, 12 and
14 point came back with 131 headings, 130 of which were paragraphs — the
report has two body sizes, and "most popular" can only pick one.

So a heading size also has to be rare. Measured over six real documents, as
the share of characters at each size above that document's body size:

heading sizes 0.01% – 5.76%
second body sizes 32.6% – 39.4%

An order of magnitude apart; the threshold sits between them at 15%, and the
code says why that is a fact about documents rather than a tuning knob.

⛔ The order of the whole thing

A numbered heading leaves a gap after its number; the table below it leaves
that same gap empty on every row; so asking the page for its tables returns
one two-column table that is the heading and the grid together — and the
grid's real columns are gone. Page 2 of a 6-page paper read as:

| 2. | Vue d’ensemble technique |
| --- | --- |
|  | HGX B300 GH200 NVL32 GB200 NVL36 GB300 NVL72 |

Nothing in the alignment says that line is a heading; its size does, and
this package is the only one that knows which sizes those are. So the tables
are looked for among the lines that are not headings, and the same page now
reads:

### 2. Vue d’ensemble technique

| HGX B300 | GH200 NVL32 | GB200 NVL36 | GB300 NVL72 |
| --- | --- | --- | --- |

No error

Nothing here can fail. The document has already been parsed to get this far,
and a page that cannot be read is an empty page — an answer, not a failure.
An error that is always nil teaches every caller to write a branch that never
runs.

How it is checked

100% of statements with no uncovered blocks, 21 mutations caught. The one
survivor is the tie-break between two equally popular sizes, which buys
determinism and not a different reading; the code says so where it is.

Several tests needed fixtures with real proportions — a document of one
heading and one short line makes that heading a fifth of the text, and the
share rule correctly calls a fifth of a document body text. Two tests failed on
that, and the fixtures were what was wrong. One test passed against the
unfixed code because its heading sat six points from its number, too narrow
to be a boundary at all; it now asserts the defect before asserting the fix.

Measured on real documents, the control last:

                           pages     h    par    ul/li     tbl
a 213-page reference work    213    695   1257  334/1989    30
a 9-page report                9      1     43    6/21       4
a 6-page technical paper       6     11     29    3/10      12
a 2-page note                  2      7     12    1/3        2
a 3-page prose report          3      0     12    3/6        0

Parity

This is the capability behind BentoPDF's pdf-to-markdown and
prepare-pdf-for-ai; the pdfops verbs come next.

🤖 Generated with Claude Code

tannevaled and others added 2 commits October 12, 2026 09:41
A PDF page has no headings, no paragraphs and no lists. It has instructions for
drawing glyphs at places, and everything a reader sees above that is an
inference. This turns those inferences into the richdoc model, which gets
Markdown, reST and HTML out of a PDF through code the fleet already has.

main was created by GitHub (ghnew), so no unreviewed commit of ours is in the
history.

WHAT A HEADING IS

Text set larger than the body, where the body size is the one the document
draws the most CHARACTERS at, over every page. That much is PyMuPDF4LLM's
IdentifyHeaders rule.

But larger is not enough, and taking it as enough was a defect this reported on
four real documents out of six: a 9-page report set in 10, 12 and 14 point came
back with 131 headings, 130 of which were paragraphs. The report has two body
sizes and "most popular" can only pick one.

So a heading size also has to be RARE. Measured over six real documents, as the
share of characters at each size above that document's body size:

  heading sizes       0.01% .. 5.76%
  second body sizes   32.6% .. 39.4%

An order of magnitude apart; the threshold sits between them at 15%.

THE ORDER OF THE WHOLE THING

A numbered heading leaves a gap after its number, the table below it leaves
that same gap empty on every row, so asking the page for its tables returns ONE
two-column table that is the heading and the grid together -- and the grid's
real columns are gone. Page 2 of a 6-page paper read as

  | 2. | Vue d'ensemble technique |

with the whole four-column comparison crushed into the second column. Nothing
in the alignment says that line is a heading; its SIZE does, and this package
is the only one that knows which sizes those are. So the tables are looked for
among the lines that are NOT headings.

NO ERROR

Nothing here can fail. The document has already been parsed to get this far,
and a page that cannot be READ is an empty page -- an answer, not a failure. An
error that is always nil teaches every caller to write a branch that never
runs.

100% of statements with no uncovered blocks, 21 mutations caught. Several of
the tests needed fixtures with REAL proportions: a document of one heading and
one short line makes that heading a fifth of the text, and the share rule
correctly calls a fifth of a document body text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things the real documents showed once the markdown came out.

A WRAPPED HEADING IS ONE HEADING. A two-page note's title broke across two
lines at a hyphen and came back as two h1s, the second reading "ager". The
guard is the same SIZE and not the same level: levels are clamped to as many as
the format has, so thirty, twenty-eight and twenty-six point can share one --
and joining on the level made one heading out of three. The distance is the
other guard, so a second heading further down stays its own.

NO SPACE AFTER A HYPHEN. "cadre man-" and "ager" joined with a space is
"man- ager", which is not what the page says; removing the line break says
"man-ager", which is. And the hyphen STAYS: dropping it reads "manager" here
and "wellknown" for a word broken at its own hyphen, and the two cannot be
told apart without a dictionary. Keeping it loses nothing a reader cannot
undo.

The same join is used in all three places a line continues another: a
paragraph, a heading and a list item.

Measured: the 213-page reference work goes from 695 headings to 618, and the
note's title from two to one.

100% of statements, no uncovered blocks, 27 mutations caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit dd2bcac into main Oct 12, 2026
1 check passed
@tannevaled
tannevaled deleted the a-pdf-read-back-as-a-document branch October 12, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant