Engineering Journal
Pdf Processor
Pdf Processor

Pdfium Heuristics Port Deepdive

2026-07-24

Under the Hood: Rebuilding a Stacked Fraction the Text Layer Never Gave You

TLDR: A display formula like (1 + 1/xⁿ)ⁿ arrives from the PDF text layer as eighteen unordered glyph runs on seven different baselines — and the fraction bar isn't in the text layer at all, because it's a drawn vector line. Reconstruction is a recursion over one number: each atom's vertical offset from the local math axis. Ported from pdf_md's C++/PDFium implementation to pdfjs run granularity, the algorithm turns raw geometry into $x \overset{\text{maps to}}{\rightarrow} y = f_n(x) = \left(1+\frac{1}{x^n}\right)^n$ with zero ML.


What the Extractor Actually Sees

Here is the math example from Prince's "magic" showcase PDF, exactly as getTextContent() delivers it (x, y in PDF points, y grows upward):

y=288.1  x=154.0  sz=10.7  [x]
y=299.4  x=166.0  sz=8.0   [maps to]
y=288.1  x=161.7  sz=10.7  [→]
y=288.1  x=198.3  sz=10.7  [y]
y=288.1  x=206.0  sz=10.7  [=]
y=288.1  x=215.0  sz=10.7  [f]
y=286.3  x=218.0  sz=8.0   [n]
y=287.8  x=224.1  sz=11.8  [(]
y=288.1  x=227.7  sz=10.7  [x]
y=287.8  x=232.4  sz=11.8  [)]
y=288.1  x=239.0  sz=10.7  [=]
y=281.7  x=248.0  sz=29.9  [(]
y=288.1  x=251.5  sz=10.7  [1 + ]
y=294.3  x=270.4  sz=10.7  [1]
y=279.1  x=268.7  sz=10.7  [x]
y=284.4  x=273.5  sz=8.0   [n]
y=281.7  x=278.6  sz=29.9  [)]
y=301.2  x=282.1  sz=8.0   [n]

Three things make this hostile to a line-based extractor:

  1. Seven distinct baselines. Any line-clustering pass shatters this into fragments: the numerator "1" on its own line, the denominator "x" on another, the exponent "n" floating above everything.
  2. The fraction bar is not here. It's a stroked path in the operator list. The text layer contains a numerator and a denominator with nothing between them.
  3. The 29.9pt parentheses lie about everything. They're stretched to the height of the whole stack. Their baseline is typographically meaningless and their size would wreck any font-relative threshold that includes them.

One Number Drives the Whole Recursion

pdf_md's insight (layout_math.cpp) is that the entire 2-D structure reduces to each glyph's offset from a math axis — the dominant baseline of the surrounding full-size glyphs. Everything else is thresholds on that offset, scaled by the local body size:

The fraction rule: a consecutive x-run of off-axis atoms that carries both a numerator and a denominator, at least one of them full-size, becomes \frac{...}{...} — each side recursing with its own re-derived axis. The full-size requirement is the guard that keeps a base with a subscript and a superscript (W_i^Q — two small atoms, one above, one below) from being misread as a fraction.

For our data: the run x(−9.0), 1(+6.2), n(−3.7) at x≈268–277 has up, down, and full-size members → \frac{1}{x^n}. Recursing into the denominator re-derives the axis at 279.1, where the small n at +5.3 is now clearly a superscript — the same offset test, new frame of reference.

The Two Traps: Stretch Delimiters and Oversets

Base-size poisoning. If the 29.9pt parens participate in base, the threshold 0.34 × base ≈ 10.2pt swallows the numerator's +6.2pt offset and the whole fraction disappears. The fix is a class: a lone bracket glyph ≥1.5× the median size is a stretch delimiter — excluded from base, excluded from axis derivation, emitted as \left(/\right), and its baseline never consulted.

Trailing vs overlapping small atoms. Two atoms in the dump are small and far off-axis: "maps to" at +11.3pt and the final "n" at +13.1pt. One is an annotation drawn over the arrow (MathML overset); the other is the exponent of the closing paren. Vertically they're indistinguishable. The discriminator is horizontal: a script trails its base (starts at or after the operand's right edge); an annotation overlaps it (starts well inside the previous atom's x-range). "maps to" starts at x=166 while the arrow spans 161.7–195.3 → \overset{\text{maps to}}{\rightarrow}. The "n" starts at 282.1 against the paren's right edge of 282.2 → \right)^n.

Granularity Is the Port, Not the Code

PDFium hands pdf_md one record per glyph. pdfjs hands us runs — [1 + ] is one item. The port explodes items into atoms (letter runs, numbers, single symbols) with x-ranges interpolated proportionally inside the run. That interpolation is coarse, and it doesn't matter: every decision in the recursion compares positions against thresholds of ~0.3× the font size, far above the interpolation error. The thresholds encode typography — how far a human typesetter offsets a numerator — and typography doesn't care which library parsed the file.

What survives the granularity change is the invariant set: the offset thresholds, the axis derivation, the full-size fraction guard. What doesn't survive is the loop structure, which was rewritten for atoms. Porting the invariants and rewriting the mechanics is the whole method.

Getting It Into the Page Without Breaking Prose

The last problem is upstream: line clustering has already shattered the stack before math code ever runs. The integration is a merge pass between clustering and paragraph building. A seed line — dominated by math-shaped atoms and carrying a hard signal (a math symbol, Greek letter, or stretch delimiter) — absorbs adjacent fragment lines whose x-span sits inside its own, with per-fragment slack for overhanging exponents. The merged atom set goes through the recursion, and the result is emitted as a protected paragraph:

<p data-math=""><span class="pdf-math">$x \overset{\text{maps to}}{\rightarrow}
y = f_n(x) = \left(1+\frac{1}{x^n}\right)^n$</span></p>

Two guards keep prose safe. The density gate: a sentence that merely mentions "→" is mostly multi-letter full-size words, fails the ≥60% math-atom requirement, and never converts. The paragraph fence: a math line always breaks paragraph from its neighbors, so the sentence-aware paragraph joiner can never fold an equation into the middle of a <p> of prose.

Read this post in the full Engineering Journal →