Skip to content

latex: reconstruct LaTeX source from a PDF - #1

Merged
tannevaled merged 8 commits into
mainfrom
reconstruct-latex-from-a-pdf
Aug 26, 2026
Merged

latex: reconstruct LaTeX source from a PDF#1
tannevaled merged 8 commits into
mainfrom
reconstruct-latex-from-a-pdf

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

A new library for the fleet: it reads a PDF and writes LaTeX source for it.

What it does

Positioned text from go-pdfkit/extract, font identity from the font
names (which for a TeX document say exactly what the author asked for),
and the rules found by walking the content stream a second time — because
a fraction is a bar with a numerator above it and a denominator below,
and without the bar there is nothing to say the two belong together.

Out of that: paragraphs, two-column reading order, \textbf/\emph/
\texttt/\textsf/\textsc, section headings and their levels,
\includegraphics with the pictures written out — and mathematics:
super- and subscripts, fractions, radicals, big operators with their
limits, \left\right, Greek and about 250 symbols, and numbered
displays as an equation environment.

The README states exactly what it does not do.

Three things measurement decided, not reasoning

Lines are built largest first, not top to bottom. Walking down the
page meets a superscript before the line it belongs to, and a radical
sign raised seven points sits nearer the line above it than the one it is
part of — so \sqrt{x} lands in the previous sentence and the page still
reads as prose.

Two columns are found from the gutter before any line is built.
Columns set to the same grid put their lines on the same baselines, so
grouping by baseline alone joins every left-hand line to the right-hand
line beside it.

An equation is repaired before it is written. A reconstruction is
worth nothing if TeX will not read it back. Handing every reconstruction
to a real TeX engine and counting is what found the faults; the rate went
27% → 55% → 97% over the same 113 papers as they were fixed. The last
of them — that \rightarrow begins with the six characters of \right,
so a text-level strip of an unmatched \left turned every limit in every
document into the undefined command \thetaarrow — is one no amount of
reading the code would have found.

Gate

go vet clean, gofmt clean, CGO_ENABLED=0, exact 100% statement
coverage
, and a build for linux amd64/arm64/riscv64/loong64/ppc64le/
s390x, js/wasm, darwin/arm64 and windows/amd64. Nothing outside the
standard library and go-pdfkit/{reader,extract,pdffont}.

Measured numbers over the arXiv corpus are in the final report; a summary
will be added to this thread when the full run finishes.

🤖 Generated with Claude Code

David Delavennat added 3 commits August 26, 2026 16:53
A PDF that came from TeX does not contain a document. It contains the
marks TeX made putting one on paper: glyphs at absolute positions, from
fonts whose names are the only surviving record of what the author asked
for, and a few thin rectangles. Everything else - that this was a
paragraph, that this word was \emph, that these eleven glyphs and one
rule were \frac{a+b}{c} - was consumed by the typesetting and has to be
worked out again from the geometry.

This reads it back. Text comes from go-pdfkit/extract as positioned runs;
what the fonts were is read from their names, which for a TeX document
say exactly what the author asked for; and the rules are found by walking
the content stream a second time, because a fraction is a bar with a
numerator above it and a denominator below and without the bar there is
nothing to say the two belong together.

Three things in here were arrived at by measurement rather than by
reasoning, and are worth naming.

Lines are built largest first, not top to bottom. Walking down the page
meets a superscript BEFORE the line it belongs to, and a radical sign
raised seven points sits nearer the line above it than the one it is part
of - so \sqrt{x} lands in the sentence on the previous line and the page
still reads as prose. Placing the body text and the headings first, and
then offering everything smaller to the nearest line that will have it,
puts each piece where it belongs.

Two columns are found from the gutter before any line is built. Columns
set to the same grid put their lines on the SAME baselines, so a page
grouped by baseline alone joins every left-hand line to the right-hand
line beside it - and the join happens before anything could notice there
were two columns.

An equation is repaired before it is written. A reconstruction is worth
nothing if TeX will not read it back, and one assembled from geometry can
say things no source would: a script with nothing to be the script of,
two subscripts on one letter, a brace left open, a \left whose \right was
on the next line. Of the first ninety-nine papers put through this, 27
compiled. The four faults above are what the other seventy-two were.

Both rule-drawing conventions are read: pdfTeX fills a rectangle, and
xdvipdfmx - what XeTeX and tectonic write through - strokes a segment. A
reader that knows only one of them silently loses every fraction in half
the world's PDFs.

go vet clean, gofmt clean, CGO_ENABLED=0, exact 100% statement coverage,
and it builds for all nine targets. Nothing outside the standard library
and go-pdfkit/{reader,extract,pdffont}.
Measured, not reasoned: reconstructing the same 113 papers and handing
each result to a real TeX engine took the compile rate from 27% to 55%
with the first repair pass, and these are what the remaining failures
were.

\rightarrow begins with the six characters of \right. The pass that
strips an unmatched \left worked on the text rather than on the tokens,
so every limit in every document became the undefined command
\thetaarrow - a repair that broke more than it fixed, and one that only a
run over real papers would show. Both the counting and the stripping are
now done on whole control words.

Counting \left and \right is not enough either. A grown delimiter that
straddles a fraction bar puts one in the numerator and the other in the
denominator: one of each, and "Extra \right", because TeX reads each
group on its own. The check is now a proper nesting test.

A prime is TeX's own shorthand for a superscript, so a subscript after
one lands on a letter that already has one; and a radical sign this
could not find a bar for has no radicand, which makes \sqrt a command
waiting for an argument that never comes. \surd is the sign on its own.
A paper whose body is nine point set on eleven, put back as LaTeX's ten
on twelve, drifts a point and a half a line: by the twentieth line it is
a whole line out, and every comparison with the original page is then
comparing text against the gap between two other pieces of text.

Both numbers are already measured - the body size to decide what is a
heading, the leading to decide what is a paragraph break - so writing
them into the preamble costs one line.

A/B on the same PDFs through the same engine, comparing where the ink
lands on the first page: mean 0.2295 with the measured size against
0.2192 without, closer in 21 of 36. Small, and in the right direction for
a reason that does not depend on the corpus.
@tannevaled
tannevaled force-pushed the reconstruct-latex-from-a-pdf branch from 2a8b310 to 1a47452 Compare August 26, 2026 15:13
David Delavennat added 3 commits August 26, 2026 17:16
arXiv source packages typeset by tectonic into the PDFs this reads back,
then compared three ways: against the author's own source, against the
original page after being set again by go-tex, and against the original
page after being set again by tectonic.

193 of 194 reconstructions compile. Words F1 0.66 median against the
author's whole source (a floor, since that source holds a preamble and a
bibliography that never reach the page); mathematics F1 0.62.

The reconstruction lands closer to the original page than the TRUE SOURCE
does when both are set by go-tex, in 193 of 194 papers - which says what
the engine can read, not that the reconstruction beats the source.

And mean absolute pixel difference gives the opposite verdict in 194 of
194, because go-tex sets less of the true source, and on a page that is
95% white an emptier page is nearer by mean difference however much of
the document it has lost. Both are in the README; only one of them is
answering the question.
@tannevaled

Copy link
Copy Markdown
Contributor Author

Measured — the full run

arXiv source packages held locally, typeset by tectonic into the PDFs this
reads back. 282 papers attempted, 194 reconstructed, 5 107 pages. The other
88 would not compile at all — they want packages, classes or figures tectonic
could not supply. Distributions, not examples.

Does it compile?

193 of 194 (99%) of the reconstructions are accepted by tectonic.

Over the same papers, as the faults were found and fixed: 27% → 55% → 99%.
The last fault was that \rightarrow begins with the six characters of
\right, so the first version of the pass that strips an unmatched \left
turned every limit in every document into the undefined command \thetaarrow
— a repair that broke more than it fixed, and one that only a run over real
papers would show.

Against what the author actually wrote

measure q1 median q3
words, F1 of the bag 0.55 0.66 0.75
word bigrams, F1 0.37 0.49 0.56
mathematics, F1 of the token multiset 0.38 0.62 0.75

The word figure is a floor: the denominator is the author's whole source,
preamble and bibliography included, none of which ever reaches the page.

Round trip, ink F1 on page one (dark pixel matched within two pixels)

q1 median q3
go-tex sets the reconstruction 0.31 0.40 0.47
go-tex sets the author's own source 0.18 0.34 0.44
tectonic sets the reconstruction 0.25 0.32 0.41

The reconstruction lands closer than the true source in 193 of 194 when both
go through the fleet's engine. That says what the engine can read, not that the
reconstruction beats the source: plain article LaTeX is something go-tex sets
in full, and a real arXiv paper is not.

The blind spot, demonstrated

Scored by mean absolute pixel difference instead, the reconstruction is
worse in 194 of 194 — the opposite verdict. go-tex sets less of the true
source, so its page is emptier, and on a page that is 95% white an emptier page
is nearer by mean difference however much of the document it has lost. Both
numbers are in the README; only one of them is answering the question.

CI

Not green, and not because of this branch: GitHub Actions is in a critical
incident
(githubstatus.com, "Incident with Actions", database primary
failover). Runs on this repo are startup_failure or stuck queued, and so are
runs on go-pdfkit/extract, whose workflow file is byte-identical
(sha1 130ca873) to this one and to go-pdfkit/reader's, which passed at 15:00
UTC just before the incident began. actionlint is clean.

The whole gate has been run locally against a fresh clone of this branch with
a cold module cache
: go vet clean, gofmt clean, go test passing at
exactly 100.0% of statements, and CGO_ENABLED=0 builds for linux
amd64/arm64/riscv64/loong64/ppc64le/s390x, js/wasm, darwin/arm64 and
windows/amd64.

The run finished. 399 arXiv source packages attempted, 275 of them
typeset by tectonic into PDFs this then read back; the other 124 would
not compile at all.

274 of 275 reconstructions (99.6%) are accepted by tectonic. Against the
author's own source: words F1 0.67 median, mathematics F1 0.61. Against
the original page, set again by go-tex and compared by where the ink
lands: 0.400 median for the reconstruction against 0.315 for the true
source, the reconstruction closer in 274 of 275.

The medians moved by less than a hundredth between 66 documents and 275,
so the sample is not what any of this turns on.
@tannevaled

Copy link
Copy Markdown
Contributor Author

The full run — final numbers

The measurement finished. 399 arXiv source packages attempted; 275 were
typeset by tectonic
into the PDFs this reads back — 7 262 pages. The other
124 would not compile at all (packages, classes or figures tectonic could not
supply). The medians below moved by less than a hundredth between the 66-paper
and the 275-paper sample, so the sample size is not what any of this turns on.

This supersedes the 194-paper figures posted earlier.

reconstructions accepted by tectonic 274 of 275 (99.6%)

Ground truth — against the author's own .tex

measure q1 median q3
words, F1 of the bag 0.56 0.67 0.75
word bigrams, F1 (order-sensitive) 0.37 0.49 0.58
mathematics, F1 of the token multiset 0.32 0.61 0.75

A floor, not a score: the denominator is the author's whole source — preamble,
macro definitions, commented-out paragraphs, bibliography — none of which ever
reaches the page.

Round trip — ink F1 on page one (dark pixel matched within two pixels)

q1 median q3
go-tex sets the reconstruction 0.32 0.400 0.47
go-tex sets the author's own source 0.19 0.315 0.43
tectonic sets the reconstruction 0.26 0.320 0.40

Closer than the true source in 274 of 275 through the fleet's engine.

The blind spot

By mean absolute pixel difference the verdict flips in 274 of 275
(0.090 vs 0.075 median, reconstruction "worse"). go-tex sets less of the true
source, so its page is emptier, and on a page that is 95% white an emptier page
scores better however much of the document it has lost. Both are reported.

CI

Greengo vet, the exact-100%-coverage assertion and all nine
cross-compile targets, on 467dd11. The earlier startup_failures were the
GitHub Actions incident, which also hit go-pdfkit/extract.

Counting the commands in 276 reconstructions against the commands in the
sources they came from, which says more about what works than any single
similarity score does.

Three rows in that table need reading rather than quoting.

\emph is emitted six times as often as the authors wrote it, and \textbf
three times. A theorem environment sets its body in italic and the page
records only the italic; every run of it comes back as \emph, which
typesets the same and reads nothing like the source. That costs on the
comparison with the source and nothing on the comparison with the page,
and it is now written down as a limitation rather than left to be
discovered.

\left comes back one time in nine, and that is mostly right: a \left(
around something that fits on one line is drawn from the ordinary roman
font at the ordinary size and is then indistinguishable from a plain (,
which typesets identically. Only a delimiter that actually grew leaves
evidence.

tabular is zero, by choice: tables were the last item of the brief and
were not reached.

Also written down: a plot included as a vector PDF is not an image but a
few thousand drawing operations inlined into the page, which is why
\includegraphics comes back about a third of the time.
@tannevaled
tannevaled merged commit 99ee1ed into main Aug 26, 2026
tannevaled added a commit that referenced this pull request Aug 26, 2026
latex: reconstruct LaTeX source from a PDF
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