Engineering Journal
Pdf Processor
Pdf Processor

We used the rendered view as our interchange format

2026-08-05

TLDR

Our document pipeline treated its rendered HTML as the interchange format. Every importer produced HTML and every exporter re-parsed it, because the HTML rendered correctly and carried structure in class names and attributes, so no separate model existed. When we built a typed model and ran an HTML to model to HTML round trip, the multi-column split ratio silently disappeared. A renderer is a projection. It serializes what it needs to draw, and a value that only becomes a CSS variable has nowhere to live.
Interchange choiceWhere structure livesColumn ratio after round tripCost of the next format
Rendered HTML (our old pivot)Class names + data attributesLost (no slot in the view)Re-infer structure from a view
Typed model (our replacement)Schema fieldsPreserved by contractOne new importer

The assumption that seemed reasonable

Our extractor produced HTML that rendered exactly like the source document. It was easy to inspect, easy to diff, easy to reason about.

We also stamped structure into it. Class names and attributes marked regions, columns, flow continuations, row metadata:

<div class="pdf-page-row" data-zones='{"cols":2}'>
  <div class="pdf-col pdf-col--left" data-col-id="col-1">...</div>
  <div class="pdf-col pdf-col--right" data-col-id="col-2">...</div>
</div>

If an exporter needed to know whether something was a header or how columns were laid out, it read those markers. The HTML was, in effect, the contract. It seemed reasonable: the view is what users see, so whatever survives in it is what the document means.


When it failed

We needed two new capabilities: DOCX import and JSON export. DOCX has structure the DOM never had: real numbered lists, merged cells, explicit column widths. Getting that structure back from HTML meant writing detection heuristics in the exporter, duplicating the extractor's work.

So we built a typed model. Every importer emits it, every exporter reads it. Then we wrote the round-trip test: HTML to model, model to HTML, compare. It passed on blocks, headings, tables, lists, confidence scores. Eight blocks in, eight blocks out.

One thing did not survive. The multi-column split ratio.

The ratio that produced this inline style on the source page:

<div class="pdf-page-row" style="--left-col: 0.38;">

was gone after the round trip. The zone descriptor carried a column count and a layout class. It did not carry the 0.38. Our model re-rendered both columns equal width. It looked correct. The geometry was wrong.


What was actually wrong

We traced it to a value with nowhere to live. The renderer read the ratio from a page-level split list, not from the zone table it serialized into the HTML. The zone table stored the number of columns, the part that determines class names. The ratio, the part that only becomes a CSS variable, was never in the data.

// What the renderer actually stored per zone:
{ y0: 120, y1: 480, cols: 2, layoutClass: 'layout-equal' }

// What the importer looked for on every zone: zone.leftFraction // undefined, every time

// What the re-renderer fell back to: leftFraction = isFinite(zone.leftFraction) ? zone.leftFraction : 0.5;

Nothing deleted the ratio. It just never had a place to be. We chose the interchange format for convenience instead of completeness. The HTML was already in memory, so it became the contract, and the contract did not include a field that mattered.


What got deleted

We deleted the pattern "every exporter re-parses the extracted HTML". Exporters now read the typed model first:

const doc = state.pdf1?.gxDoc;
if (doc) return exportFromModel(doc);          // typed path
return exportFromDom(state.pdf1.extractedHTML); // fallback for pre-IR docs

The DOM walk was not ripped out. It was demoted to a fallback for documents that predate the model. That is how the migration shipped without a rewrite.


What replaced it

A typed schema where every value that must survive has a named slot: zones, columns, alignment, runs, table confidence, flags. The rule is explicit. If a value needs to round-trip, it gets a field. If it only affects rendering, the view owns it and the model does not promise to preserve it.

The column ratio still leaks today. It is the known cost of choosing the view as the pivot first. The fix is a named slot in the model and a one-line change in the renderer to write the ratio into the zone table it serializes.


The generalizable lesson

Before choosing an interchange format, enumerate the values that must survive a round trip, and verify each one survives. At least one will be missing, and it will be a value that is structurally important but visually quiet, like a column ratio or a footnote flag, because those are the ones the renderer does not need to store.

A view is a promise to draw a document, not to describe it. Reversing an exporter's input from the view's output is a round trip through a projection, and projections lose information by design.

Rule of thumb: If a value must survive a round trip, give it a named slot in a model before you trust any view to carry it. Test the round trip for every field, because the missing one is always the one you did not think to check.
Read this post in the full Engineering Journal →