Why PDF-to-HTML pipelines produce nested wrapper divs and how to flatten them
TLDR: PDF extraction engines know where text is, not what it means. They wrap every element in positional containers (regions, zones, font buckets) that cascade into 3+ levels of nesting. The fix is not to eliminate those wrappers from the extraction pipeline but to transform the output after extraction: extract the inner <p> content and rewrap with the combined semantic classes in a single tag. This produces valid, clean, reflowable HTML that editors and CSS frameworks can work with.
The problem: spatial extraction does not produce semantic HTML
A PDF extraction engine works in pixel space. It sees text items at coordinates (x=643, y=949) with a specific font, size, and style. It groups nearby items into regions. It groups regions with the same column assignment into zones. It assigns numbered font classes (.f0, .f1) to every unique (family, size, bold, italic) combination.
The output looks like this:
<div class="pdf-region" data-ry="949" data-rx="643">
<div class="f0 ta-l">
<p>Technical Supplement to the 2026 Budget</p>
</div>
</div>
That is three levels of wrapping for one paragraph. The .pdf-region div carries pixel coordinates for the zone toolbar. The .f0.ta-l div carries font and alignment. The <p> carries the actual text.
Every wrapper exists for a legitimate reason during the extraction lifecycle. But none of them serve the person who wants to edit the document in a contenteditable surface, export clean HTML, or apply Tailwind classes.
The naive approach: add more wrappers for each new feature
The natural engineering reflex is to add another wrapper div when a new feature needs metadata. The zone toolbar needs pixel coordinates, so we added <div class="pdf-region" data-ry="..." data-rx="...">. The font system needs CSS classes, so we added <div class="f0 ta-l">. The flow chain system needs continuity attributes, so we added id="flow-1" data-flow-next="flow-2" on the font wrapper.
Each addition makes sense in isolation. Together they create a nesting problem that gets worse with every feature. After a dozen features, paragraphs are nested 4 or 5 levels deep. The HTML is semantically correct but practically uneditable.
The better model: post-process the extraction output
The insight is that the extraction pipeline should produce one format (the most expressive one, with all metadata), and a separate transformation step should produce the clean version for human consumption. This separates concerns:
- The extraction pipeline (in the Web Worker) produces rich HTML with all positional metadata
- A post-processing step in the main thread transforms it into clean, flat HTML for display
- The rich version stays in state for zone operations and re-extraction
rebuildText() (which wraps paragraphs in <p> tags with inline styles) and extract the inner content:
const pBlocks = [];
const pRe = /<p>([\s\S]*?)<\/p>/g;
let pm;
while ((pm = pRe.exec(paraHtml)) !== null) {
pBlocks.push(pm[1]);
}
html = pBlocks.map((content, fi) => { const attrs = fi === 0 ? firstFlowAttrs : ''; return <p class="${fontClass} ${alignClass} pdf-paragraph"${attrs}>${content}</p>; }).join('\n');
The .fN and .ta-x classes that were on the wrapper div now go directly on the <p> tag. The pdf-paragraph class provides a stable semantic anchor. The flow-chain attributes (which were on the wrapper div) go on the first <p> in the region.
The output becomes:
<div class="pdf-region" data-ry="949" data-rx="643">
<p class="f0 ta-l pdf-paragraph">Technical Supplement to the 2026 Budget</p>
</div>
One fewer level of nesting. Every class that was on the intermediate div now lives on the <p> directly. The zone toolbar still works because it operates on .pdf-region elements and their data-* attributes, never looking inside them.
Why this matters for CSS frameworks
A developer opening the extracted HTML in an editor sees <div class="f0 ta-l"><p>text</p></div>. They cannot style this with Tailwind without knowing what .f0 resolves to. They cannot use Bootstrap spacing utilities because the <p> is trapped inside a wrapper div that has no semantic meaning.
With the flattened structure, they see <p class="f0 ta-l pdf-paragraph">text</p>. The pdf-paragraph class is a stable hook: Tailwind users write .pdf-paragraph { @apply my-4 text-base; }. Bootstrap users write .pdf-paragraph { margin: 1rem 0; }. The .f0 class still provides precise font sizing from the PDF, but it is no longer the only way to target the element.
Tradeoffs and edge cases
This approach requires parsing the HTML output of a sub-module (rebuildText) with a regex. That is fragile in principle but stable in practice because rebuildText always wraps its output in
tags when using format: 'html'. A fallback path preserves the old structure when no
tags are found (which can happen when textRebuilder classifies every line as a heading based on font size heuristics).
The zone toolbar is the one consumer that genuinely needs the positional metadata. It reads data-ry and data-rx from .pdf-region elements to determine region order and column assignment. These attributes are invisible to the user (they are data attributes on a div that wraps the paragraph) and do not interfere with editing or styling.
The flow chain attributes (data-flow-next, data-continuation) move from the wrapper div to the
element. The CSS that styles them needs a corresponding selector update: [data-continuation] > p:first-child becomes [data-continuation] since the
` is now the element carrying the attribute.
The generalizable principle
Any pipeline that produces HTML from a structured data source (PDF extraction, markdown compilation, template rendering) should keep its internal metadata layer separate from the human-facing output layer. The metadata layer is rich, verbose, and complete. The human-facing layer is flat, semantic, and clean. A post-processing step bridges the two, and it should be a pure function with a clear contract: take the rich HTML, return the clean HTML, fail gracefully on unexpected input.