Engineering Journal
Pdf Processor
Pdf Processor

Pdfium Heuristics Port Errorfix

2026-07-24

Four Fixes Between "Tests Pass" and "The Formula Is Right"

TLDR: The math reconstruction port passed its unit tests on the first run — and produced the wrong formula on the real document. Every one of the four fixes was a case where the algorithm was faithful to its source and wrong about our data: a consume-loop that swallowed oversets, a 2pt tolerance that rejected a 4pt overhang, a paragraph joiner that ate the equation, and an alignment detector blinded by its own bounding box.


Fix 1: The Script Loop Ate the Overset

Symptom: x \rightarrow^{\text{maps} \text{to}} y = ... — the "maps to" annotation drawn over the arrow rendered as a superscript of the arrow.

Root cause: The port kept pdf_md's trailing-script consume loop verbatim: after emitting an operand, consume every following small off-axis atom as a sub/superscript. "maps to" is small and off-axis, and in x-order it follows the arrow. The loop's condition encoded an assumption about PDFium's data — oversets rarely surface there as separate trailing runs — that doesn't hold for pdfjs items.

Fix: Scripts trail; annotations overlap. One x-guard in the loop condition:

while (i < n && scriptLevel(gl[i]) !== 0 &&
       gl[i].left >= op[op.length - 1].right - 0.5 * gl[i].size) {

An atom starting well inside the previous operand's x-range now falls through to the fraction-level branch, where the overlap case emits \overset{\text{maps to}}{\rightarrow}.

Lesson: A consume-loop's condition is a claim about what the input can contain. Port the loop, re-derive the claim.

Fix 2: A 2pt Window Rejected a 4pt Exponent

Symptom: The trailing leaked out of the math group and appeared in the prose as a bare "n".

Root cause: The merge pass absorbs shattered stack fragments whose x-span fits inside the seed line's span ± 2pt. The exponent rides the closing stretch paren — it starts at the paren's right edge and extends 4pt past the seed's x-extent. Fixed tolerance, variable glyph.

Fix: Slack scaled by the candidate, not a constant: max(2, 0.8 × fragmentSize). An 8pt exponent gets 6.4pt of overhang; a prose line at body size still can't sneak in because its left edge misses the window by 50pt.

Lesson: Any fixed-point tolerance around glyph geometry is a bug waiting for a font-size change. Scale by the thing being tested.

Fix 3: The Paragraph Joiner Swallowed the Equation

Symptom: <p data-math="">Prince 6 add experimental support for MathML. Here is an example: ... $x \rightarrow ...$</p> — intro prose and formula fused into one block, the whole thing styled as math.

Root cause: Two independent mechanisms conspired. The vertical gap between "an example:" and the formula (29pt) sat under the paragraph threshold, so they clustered into one paragraph. Then the sentence-aware joiner — built to heal PDF line breaks mid-sentence — happily continued the open sentence into the math block.

Fix: A math line is a hard paragraph boundary in both directions:

const mathEdge  = !!lines[li].math !== !!lines[li - 1].math;
const isParaBrk = gap > paraThreshold || prevEOL || mathEdge;

plus a !b.isMath guard in the joiner, mirroring the existing heading guard.

Lesson: Every "merge adjacent things" pass in a pipeline must know about every "this thing stands alone" type. The joiner knew about headings; math was a new standalone type and had to be registered with it.

Fix 4: Alignment Can't Be Detected From a Tight Bounding Box

Symptom (the original bug report): a centered page-number footer extracted with no alignment at all — the user had to hand-write style="text-align: center".

Root cause: The old _inferAlignment required ≥2 lines (a single line bailed to left), was never called for footers, and measured everything against the region's own bbox. A region bbox is fitted tight to its text: a single centered line and a single left-aligned line have identical geometry relative to their own box. The information simply isn't there.

Fix: Build a comparison frame the block didn't define: each column's frame is the union x-extent of its sibling regions, full-width blocks measure against the page content frame. A single line with roughly equal margins inside that frame is centered; right-flush with a big left margin is right-aligned. Bonus fix in the multi-line path: the short last line of a justified paragraph was wrecking the right-edge variance, so justified text classified as left — last lines are now excluded from edge statistics.

Lesson: When a classifier keeps answering "default" on inputs humans classify instantly, check whether the discriminating information is even present in what you're feeding it.

Read this post in the full Engineering Journal →