Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
277 changes: 275 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,278 @@
# latex — go-pdfkit

Reconstruct LaTeX source from a PDF.
[![License](https://img.shields.io/badge/license-BSD--3--Clause-blue)](LICENSE)
[![Go](https://img.shields.io/badge/go-1.26.4%2B-00ADD8)](https://go.dev/dl/)
[![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](#the-gate)

Under construction.
**Reconstruct LaTeX source from a PDF.** Pure Go, no cgo, no dependencies
outside the fleet.

```go
d, _ := reader.Open(pdfBytes)
doc, _ := latex.Reconstruct(d, latex.Options{})
os.WriteFile("out.tex", []byte(doc.String()), 0o644)
doc.WriteFiles(".") // the pictures the \includegraphics commands name
```

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 package does that work. It is a **reconstruction and not a recovery**: two
different sources typeset to the same page, and where the geometry cannot tell
them apart this makes a choice and says which one.

## What it reconstructs

**Paragraphs and lines.** Baselines into lines, lines into paragraphs. A
paragraph is found from three things at once — the first line of one is
indented, the last line of one is short, and a document that uses vertical
space instead of an indent leaves a wider gap — with the short-line test only
trusted after checking, over the whole document, that its lines reach the right
margin at all.

**Two columns.** Found from the gutter: a strip down the middle of the page that
no glyph enters. This has to be found *before* the lines are built, because two
columns set to the same grid put their lines on the same baselines, and a page
read by baseline alone comes back with every left-hand line joined to the
right-hand line beside it.

**Font changes.** `\textbf`, `\emph`, `\texttt`, `\textsf`, `\textsc`, read from
the names of the fonts the text was set in — CMBX10, CMTI10, LMRoman10-Bold,
NimbusRomNo9L-Medi. Nothing looks inside the font program: a font's own idea of
its weight is often wrong, and a subsetted face carries no useful flags at all.

**Section headings and their levels.** From the number the author gave them
where there is one — "3.2.1" is a `\subsubsection` and nothing else — and from
the ranking of heading sizes across the document where there is not. An
unnumbered heading comes back starred. The title of a paper becomes `\title`
and `\maketitle`.

**Mathematics.** The part that matters, and the part that is difficult.

- **Super- and subscripts**, recursively: a smaller glyph raised or dropped.
- **Fractions**: a horizontal rule with material above and below it. Both
producers are read — pdfTeX fills a rectangle, xdvipdfmx (what XeTeX and
tectonic write through) strokes a segment — because a reader that knows only
one of them silently loses every fraction in half the world's PDFs.
- **Radicals**: the hook glyph plus the bar drawn over the radicand, which is
what says how far the radicand reaches.
- **Big operators with their limits**, recognised by the limits sitting *over*
the operator rather than after it.
- **`\left(` … `\right)`** from the extension family's grown delimiters, when
they pair up in the equation; when they do not, the plain character.
- **Greek and about 250 symbols**, and the operator names — `\sin`, `\log`,
`\max` — that TeX sets upright.
- **Numbered displays** come back as an `equation` environment with the number
taken off, since writing it back would give the equation two numbers.

**Where an equation starts and stops** is itself a reconstruction: TeX sets the
`$` and the text around it in the same roman font, so the boundary leaves no
trace. The three math families are the seeds; around each one the equation is
grown over the characters the roman font also drew — the digits, the `+`, the
parentheses — recognised by being the sort of character an equation contains
*and* by being closer than a word space. Both halves matter. There is also a
rule for one widely used font package, Fourier, which sets its math *letters* in
the text italic face, so that "(L, P)" is drawn with roman parentheses and
italic letters and nothing in any font name says it is an equation: a letter set
in italic, alone, with roman on both sides of it, is a variable, because
emphasis applies to words and a word is more than one letter.

**Pictures**, as `\includegraphics` with the file written out beside the source.
A JPEG or a JPEG 2000 is written as it stands; plain samples in DeviceGray,
DeviceRGB or a stencil mask become a PNG; anything else becomes a `\framebox` of
the right size rather than a command pointing at a file that is not there.

**Output that compiles.** The last thing done to an equation is to read it back
the way TeX will and repair what TeX would refuse: a script with nothing to be
the script of, two scripts of one kind on one letter, a brace left open, a
`\left` whose `\right` was on the next line. This is not a nicety — an aborted
compile loses the whole document rather than the one equation.

## What it does not

- **Bibliographies, citations, cross-references, labels.** A `\cite` that
typeset to "[14]" comes back as the characters `[14]`; there is nothing on the
page that says otherwise. Same for `\ref` and `\label`.
- **Tables.** The rules are read (a fraction needs them) but nothing is made of
a grid of them. A table comes back as the lines of text it is made of.
- **Footnotes**, which come back as text at the foot of the page.
- **Telling a class's italic from the author's `\emph`.** 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: `\emph` is emitted about six times as often as the authors wrote it,
and `\textbf` about three. The same applies to any class that sets something
bold or italic on its own account.
- **A figure drawn rather than placed.** `\includegraphics` comes back for a
picture the page places as an image; a plot that the author included as a
vector PDF is not an image at all but a few thousand drawing operations
inlined into the page, and this recovers about a third of the
`\includegraphics` commands in the corpus for that reason.
- **Colour.**
- **Accents.** A PDF draws `\hat{L}` as an L and a circumflex placed over it,
two glyphs, and there is no way to write that back without knowing which glyph
the accent belongs to. The accent is dropped and the letter kept, rather than
a character being written into the source that no engine will set.
- **The abstract environment**, which comes back as a centred paragraph.
- **The preamble.** It is reconstructed from what the body needs — amsmath,
amssymb, graphicx, and the page geometry measured off the first page — rather
than from what the author wrote, which no PDF records.
- **Whitespace fidelity.**
- **A page whose producer scales text with the text matrix rather than with the
font size.** `github.com/go-pdfkit/extract` reports the font size, and a
producer that sets `/F1 1 Tf` and scales by twelve reports a size of one for
everything on the page. TeX-produced PDFs — which is what this is for — set
the real size; drawing programs often do not, and on such a page the size
tests that find headings and scripts have nothing to work with.


## Measured

On arXiv source packages held locally: **399 papers attempted, 275 typeset by
tectonic** (a real TeX engine) into the PDFs this then read back — **7 262
pages**. The other 124 would not compile at all: they want packages, classes or
figures that tectonic could not supply. Everything below is the distribution
over those 275, not one example of one.

### Does it compile?

**274 of 275 (99.6%)** of the reconstructions are accepted by tectonic.

That number is the whole point of the repair pass, and it was not free. Over the
same set of papers, as the faults were found and fixed, it went

| | reconstructions that compile |
|---|---|
| before any repair | **27%** |
| after the first repair pass | **55%** |
| after three more faults were fixed | **97–99%** |

The faults were: a script with nothing to be the script of; two scripts of one
kind on one letter; a brace left open; a `\left` whose `\right` was on the next
line or inside a different group; a prime, which TeX reads as a superscript, so
a following subscript lands on a letter that already has one; a bare `\sqrt`
with no radicand; and — the one that no amount of reading the code would have
found — 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`.

### How close is it to what the author wrote?

The reconstruction is compared with the paper's own `.tex` files, with comments
stripped, commands and braces removed, and mathematics compared separately.

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

The word figure is a floor rather than a score: the denominator is the author's
whole source, which contains a preamble, macro definitions, commented-out
paragraphs and a bibliography, none of which ever reach the page and none of
which this could recover.


### Which of it comes back, construct by construct

Counting the commands in the 276 reconstructions against the commands in the
`.tex` files they came from. The denominator is generous — it counts every
`.tex` in the package, including files the main document never inputs, and
macro definitions — so these are lower bounds on the right-hand side and the
ratios understate. They are still the clearest statement of what works.

| construct | in the reconstructions | in the sources |
|---|---|---|
| `\section{` | 1 331 | 2 707 |
| `\subsection{` | 1 432 | 3 572 |
| `\begin{equation}` | 4 673 | 9 516 |
| `\frac{` | 6 607 | 24 066 |
| `\sqrt{` | 994 | 3 796 |
| `\left` | 1 832 | 17 238 |
| `\includegraphics` | 1 082 | 3 094 |
| `\texttt{` | 2 983 | 2 377 |
| `\textbf{` | 20 270 | 6 893 |
| `\emph{` | 40 253 | 6 424 |
| `\begin{tabular}` | **0** | 746 |

Three of those rows are worth reading carefully.

**`\emph` and `\textbf` are over-emitted, by six times and three.** A theorem
environment sets its whole body in italic, and this has no way to know that the
italic came from `\begin{theorem}` rather than from `\emph` — so it writes one
`\emph` per run of italic text. The page typesets the same; the source does not
look like the author's. The same happens to `\textbf` wherever a class sets
something bold on its own account. This costs on the comparison with the source
and nothing at all on the comparison with the page.

**`\left` comes back one time in nine.** That is mostly correct rather than
mostly missing: 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, and that is what this recovers.

**`tabular` is zero, by choice.** Tables were the last item of the brief and
were not reached; a table comes back as the lines of text it is made of.
### Round trip: typeset the reconstruction and compare the pixels

The reconstruction is set again — once by the fleet's own `go-tex/engine`, once
by tectonic — and each rendering is compared with the original page by
`go-pdfkit/render`.

The comparison is **where the ink lands**, not the mean pixel difference: a dark
pixel in one page counts as matched when there is a dark pixel within two pixels
of it in the other, scored as an F1. Mean absolute difference has a blind spot
on a page that is mostly white — drawing the right thing one pixel off scores
worse than drawing nothing — and this corpus shows it plainly, below.

The engine's own fidelity has to be separated from the reconstruction's, so the
same engine also sets the paper's **true source**, and the two are compared:

| page one of 275 papers, ink F1 against the original | 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 |

**The reconstruction lands closer to the original page than the true source
does, in 274 of 275 papers**, when both are set by the fleet's engine. That is
not a claim that the reconstruction is better than the source. It is a statement
about what the engine can read: the reconstruction is plain `article` LaTeX with
amsmath and graphicx, which go-tex sets in full, while a real arXiv paper pulls
in classes and packages it drops. A reconstruction faithful enough to stand in
for the source under an engine that cannot read the source is the useful thing
being measured here.

### The blind spot, demonstrated

The same 275 comparisons, scored by **mean absolute pixel difference** instead:

| | q1 | **median** | q3 |
|---|---|---|---|
| go-tex sets the reconstruction | 0.071 | **0.090** | 0.104 |
| go-tex sets the author's own source | 0.054 | **0.075** | 0.093 |

By that measure the reconstruction is *worse* in **274 of 275** — the exact
opposite verdict. The reason is that go-tex sets less of the true source than of
the reconstruction, so its page is emptier, and on a page that is 95% white an
emptier page is nearer the original by mean difference however much of the
document it has lost. Both numbers are reported because only one of them is
answering the question.

### What it costs

Median 18 seconds per paper end to end, which is dominated by the two TeX
compiles; the reconstruction itself is a fraction of a second per page.
## The 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 `github.com/go-pdfkit/{reader,extract,pdffont}`.

## Licence

BSD-3-Clause.
Loading
Loading