Engineering Journal
Pdf Processor
Pdf Processor

Path Reconciler Hottake

2026-05-30

Hot Take: Stop Solving PDF Structure at Render Time

TLDR: The reason most frontend PDF extraction is wrong is that developers try to infer document structure from the rendered visual output instead of from the operator stream. De Casteljau subdivision, pixel-based column detection, raster-scan zone boundaries -- these are workarounds for not reading the data correctly in the first place.


The Industry Default Is Backwards

Here is what most frontend PDF tools do: render the page to a canvas at some scale, read text positions from getTextContent(), optionally run a computer vision model over the canvas, then try to infer whether those text positions form columns, tables, or lists based on pixel proximity.

This is backwards. The PDF already contains explicit structural information in the operator stream. The table you are trying to infer from pixel columns is drawn with literal path operators: moveTo, lineTo, rectangle, stroke. The zone boundaries you are trying to detect from text Y-positions are encoded in the CTM stack and fill operations. You are trying to reconstruct from the rendered artifact what was always explicit in the source.


The De Casteljau Example

I proposed de Casteljau subdivision for Bezier bounding boxes in my own architecture document and then rejected it during stress testing.

De Casteljau is a subdivision algorithm. You split the curve at its midpoint, recursively subdivide both halves, and stop when the control point hull is small enough. Then you use that hull as an approximation of the bounding box.

This is the right algorithm when you need to render the curve or find the nearest point on it. It is the wrong algorithm for bounding boxes because:

  1. You choose a stopping tolerance. Too loose and the bbox is wrong for diagonal curves. Too tight and you recurse deeply on every curve, including simple arcs.
  2. The segments you produce go through downstream filtering. A minLen guard eats the short segments from the recursion tail. Now your bbox is even more wrong.
  3. There is no reason for any of this. The analytical solution is one application of the quadratic formula. It is exact. It does not recurse. It does not allocate segments.
The correct algorithm has been in every graphics textbook for 40 years. We still reach for subdivision because it feels intuitive -- you can watch it work visually, you can tune the tolerance, it feels like you understand what it is doing. But "feels intuitive" is not a correctness argument.

The Zone Boundary Example

The page assembly design philosophy document I updated this session contains three zone detection bugs. One of them: _detectAutoZones computes zone boundaries as midpoints between yCenter values of adjacent region groups.

The yCenter of a region is the midpoint of its bounding box. The midpoint between two region yCenter values is some coordinate inside the gap between them -- maybe. But a region's ry (used in the zone filter) is Math.round(region.yCenter), not the region's top edge. Sub-pixel rounding can place a region in the wrong zone.

The fix is simple: use bbox.y (the region's actual top edge) for zone boundaries, and compare against rBboxY (the region's top edge rounded to pixel) in the filter. Every region's top edge is the natural zone membership criterion -- it is where the region starts.

The midpoint heuristic exists because it sounds reasonable: "put the boundary halfway between the two groups." But zone membership should be determined by where content starts, not by a geometric midpoint between content centers. The former is structural. The latter is visual.


What the Correct Approach Requires

It requires reading the operator stream correctly, not just getTextContent(). It requires building a CTM stack and tracking matrix state per subpath. It requires classifying subpaths geometrically (RECT vs FREE_PATH) before doing any analysis. It requires emitting canonical segments with provenance, not just (x1, y1, x2, y2) tuples.

This is more work than pixel-based heuristics. But it produces deterministic output for the same PDF across renders, across scale factors, across pdfjs versions. Pixel-based output is not deterministic -- it depends on anti-aliasing, subpixel positioning, font hinting.

A PDF extractor that gives you different columns on the same document at 100% and 150% zoom is not extracting structure. It is pattern-matching visual artifacts and calling it structure.


The Uncomfortable Implication

If you are building a PDF extraction tool for production use and you are not parsing the operator stream, you are building a demo. You can get it to work on the 20 PDFs in your test suite. You cannot get it to work reliably on the PDFs your users will upload.

The path through the operator stream is harder. It requires understanding the CTM, the fill/stroke state machine, the constructPath compound op, the difference between setFillRGBColor and setFillColorN, the fact that curveTo2 and curveTo3 are shorthand variants with implicit control points. These are not documented anywhere obvious; you read the PDF spec and the pdfjs source.

But you only have to understand it once. Then it works for every PDF, not just the ones you tested on.

Read this post in the full Engineering Journal →