Engineering Journal
Pdf Processor
Pdf Processor

The denominator thief: when local pairing steals another structure's atoms

2026-08-20

TLDR

A fraction detector that pairs atoms directly looks correct on 1/2 and corrupts every nested expression it touches. An atom below your numerator may belong to a different fraction entirely. The fix was to stop pairing atoms and start classifying rows, then pair rows with a vertical-blocking rule. The general lesson: when a pipeline flattens structure, do not rebuild it with the same locality the flattening destroyed.

The assumption that seemed reasonable

The extraction pipeline receives flat text items: every glyph has a position, a size and a baseline, and nothing else. A fraction is visibly two glyphs on two baselines with a rule between them. The reasonable assumption was that structure could be recovered locally. Take each atom, look for an atom above and an atom below, and when they exist, emit a fraction.

It passed on the first test. 1/2 became a fraction. x + 1/2 became a term and a fraction. The unit tests were green and the shape looked right.

When it failed

The failure appeared in a document full of series notation. Summations with limits above and below, fractions nested inside fractions, binomial coefficients. The output was plausible and wrong. One case in particular: a fraction with a subscripted denominator followed by another fraction's numerator, a/b with a superscript, then c/d sharing the baseline.

The per-atom rule paired greedily. The atom below the first numerator got claimed by the first fraction, even when that atom was the numerator of a second fraction one row further down. The result was a three-layer monster: a numerator on top, a stolen middle layer, and a denominator that belonged to a different expression entirely. The LaTeX emitted was syntactically valid and semantically nonsense.

Worse, it failed silently. No exception, no empty output. The expressions rendered, and they rendered wrong.

What was actually wrong

The rule operated at the wrong granularity. A numerator and a denominator are not two atoms, they are two rows, and the atoms inside those rows are decided by row membership. Pairing atoms directly meant every atom was a candidate for every structure, and nothing could veto a pair. There was no concept of "another structure sits between these two atoms", because the algorithm never knew rows existed.

The second defect was the gap measurement. A superscript hanging over a gap looked like it closed the gap, because the gap was measured against the previous atom's right edge. The superscript's overhang covered the hole, and two separate columns of glyphs got welded into one row.

What got deleted

The per-atom pairing pass is gone entirely. Its replacement is a row-classification pass that runs before any structure is assigned.

What replaced it

Three passes, in order.

First, cluster glyphs into rows by baseline, with a tolerance tight enough that a fraction's two halves never merge. Second, split rows at real horizontal gaps, measuring each gap against the farthest right edge of any atom inside the span so a hanging superscript cannot weld two columns together. Third, pair rows as numerator and denominator only when no other full-size row sits in the band between them, and recover nesting by pairing the tightest row pair first.

// The veto that the per-atom rule never had
function canPair(rows, i, j) {
  if (Math.abs(rows[j].baseline - rows[i].baseline) > 1.8 * base) return false;
  if (!rowsOverlap(rows[i], rows[j], 0.4)) return false;
  return !rowsBetween(rows, i, j); // a third row in the band vetoes the pair
}

The blocking rule is what keeps a/b + c/d from merging into one four-layer stack. The plus sign sits between the two fractions at the same baseline, and it is a full-size row. A row between the pair is a veto. The tightest-pair-first ordering is what keeps a fraction inside a fraction nested correctly instead of siblinged.

The generalizable lesson

When a pipeline flattens structure, it destroys the very relationships the reconstruction needs. Rebuilding with local proximity is rebuilding blind. Recover the intermediate representation first: rows, axes, sizes. Then let every structure decision read from that model. A rule that can consume another structure's atoms will, eventually, and it will do it without erroring.

The test suite now pins the nested cases, the separated operator case, and the exact LaTeX for twenty-five expressions. The fix is proven load-bearing: reverting the row pass fails the nested fraction assertion, the blocking assertion and the gap-weld assertion at once.

Read this post in the full Engineering Journal →