Known limitations of document-to-form conversion, with the reasoning behind each and the roadmap for addressing them.
Conversion — Known Limitations and Roadmap
What the AI conversion pipeline cannot do today, why each limit exists, what to do about it right now, and the tracked issue that closes it. Companion to PDF-TO-FORM, which describes what conversion does.
The design stance behind all of this: deterministic where possible,
model-guided where not, graceful when both miss. Structure the extractor can
prove (a repeating log's <thead>, a transposed matrix's label column) is
handed to the model as an explicit hint — every hinted shape has converted
reliably; every misconversion so far traces to a shape the detectors could not
see. So most rows below are, at heart, "a shape the detectors cannot see yet."
| # | Limitation | Severity | Workaround today | Fix tracked in |
|---|---|---|---|---|
| 1 | <table>-only |
Resolved | — (requires Chromium; without it, markup-only detection as before) | #72 ✅ |
| 2 | Resolved | — (hints are read off the page, so they are a careful reading rather than a proof; HTML remains the most reliable source) | #73 ✅ | |
| 3 | Resolved | — (only those two patterns are spared; other hidden content is still stripped, and a revealed section's condition is inferred from the form's text, so check it in review) | #74 ✅ | |
| 4 | Scripted behaviour — partly fixed: declarative config converts with an explicit opt-in, and script-revealed sections and computed result banners are rebuilt from the form's own stated rules; other imperative behaviour still does not | Low | Tick "Read option lists from this mock-up's scripts"; check the rules and outcome wording in review; add anything else in the designer | #75 ◐ |
| 5 | Resolved | — (requires Chromium; HTML_PROBE_DISABLED=1 reverts to a single read) |
#76 ✅ | |
| 6 | Size caps: 160 fields / 160 table rows / 32k chars / 2 MB per HTML upload (defaults; deployment-configurable via CONVERSION_MAX_* env vars) |
By design | Split into one file per section, or the operator raises the caps for capable providers | — |
| 7 | Fidelity is structural, not pixel-exact | By design | Print engine reconstructs A4 from the Print Schema | — |
1. Structure detection is <table>-only — resolved
Was: the detectors that make conversion reliable — findRepeatingTables
(records as rows) and findTransposedMatrices (records as columns) — walk
<table>/<thead>/<tbody> markup. A mock-up that draws the same grid with
<div>s and CSS grid/flexbox was invisible to them, and AI-generated mock-ups
increasingly do exactly that.
Now (#72): detection
no longer depends on markup shape. The sandboxed Chromium that already executes
mock-ups also reports where every label, control and button landed, and
layout-detect.ts clusters those boxes into rows and columns. Where a pixel
sits does not care how the pixel got there, so the same two shapes are found
whether the grid was a <table>, CSS grid, flexbox or absolute positioning —
and the output is the same RepeatingTableHint / TransposedMatrixHint, so
nothing downstream changed.
How it behaves:
- Markup wins. Geometry runs only when the markup detectors found nothing.
A real
<table>is the more precise statement of intent; clustering pixels could only blur it. - A render is only spent when it could change the outcome. A script-free document is rendered for geometry only if it shows fields, yields no repeating structure, and names an "Add …" control — the same precondition the detector itself applies. A details panel with Save/Print buttons costs nothing.
- No add affordance, no hint. A print-only grid the clinician fills by hand stays a plain set of fields rather than becoming a record table.
- Chromium is required for this path. Without a browser, detection falls back to markup-only exactly as before — no error, just the old behaviour. See ADR-003 for the isolation model.
Residual limits: the structure must be visible when the page loads — a matrix behind a collapsed panel is still missed (row 5), and group boundaries within one long parameter column are still inferred rather than measured.
2. PDFs and images get no structural hints — resolved
Was: every structural hint came from HTML markup. A PDF or image went to the model as page pictures plus text, with no repeating-log hint and no matrix hint, so a scanned cannula-style matrix converted on prompt rules alone.
Now (#73): a narrow
vision pre-pass runs before the conversion and asks the pages one question —
what repeating table structures are on them? The reply is validated against a
strict shape and turned into the same REPEATING LOG: / MATRIX TABLE:
paragraphs the HTML detectors emit. See Structure hints for PDFs and
images.
Measured on a PDF of the VIP cannula chart: the pre-pass read all 22 row labels and the instance heading off the page image, matching what the HTML detector produces for the same form, and the PDF converted to a single record table.
Not the same guarantee as HTML. A markup hint is something the extractor proved; a page hint is a vision model's reading of a picture. It is validated before use and it is introduced to the conversion as an observation to reconcile against the pages, not as law. If a form exists as both PDF and HTML mock-up, the HTML is still the more reliable upload.
Why every hint is discardable. A malformed or hallucinated hint is worse than none — it steers the conversion wrong with confidence. So a reported table is dropped whole if any label is unusable, if the model flagged low confidence, if a matrix has fewer than 3 rows or a log fewer than 2 columns, or if the reply does not parse. Partial row lists are never salvaged.
Classical line detection on the pdftoppm images was the other candidate in
the issue. It is not implemented: the vision pre-pass covers ruled and unruled
tables alike, and adds no image-processing dependency.
3. Hidden conditional fields are stripped — resolved
Was: the extractor removes display:none content deliberately, because
hidden markup is the classic place to smuggle instructions to the model. The
collateral damage was legitimately hidden fields — the "Please specify…" input
that appears only when a select is set to "Other". The VIP form lost 2 of its 23
fields that way.
Now (#74): the extractor recognises that one narrow pattern before the strip, keeps the field, and tells the model to emit it with a SHOW rule bound to its select. The VIP form converts to all 23 fields. See Conditional fields.
General hidden-content stripping is unchanged. What is spared is deliberately tiny:
- only an
<input>or an empty<textarea>— never a container, so no hidden prose rides along. (<textarea>is not void, so a populated one is treated as prose and stays stripped.) - only beside a
<select>that actually offers an "Other"-style option, and only within that select's own parent. - only text-entry inputs — a hidden checkbox, radio or
type="hidden"is not a "specify" companion. - the one string a hidden element can newly put in front of the model is its own label, capped at 60 characters.
Progressive disclosure is the second spared pattern. Clinical worksheets hide
whole sections, not just one input: CAM-ICU ships Features 2-4 as
display:none rows its script reveals in turn, and stripping them turned a
four-feature delirium assessment into a one-question form. findConditionalFields
could not help — it spares a lone text input, never a container.
findScriptToggledSections now spares a container whose visibility the page's
own script toggles, provided it holds a real field and stays inside the size
caps (1,500 characters per section, 6,000 per document, 12 sections). The model
is told to gate it with a rule on the OmfTableRow. See
Progressive disclosure
for the full table of what is spared and what is not.
Computed result text is the same story one step on. A worksheet that
computes an outcome keeps every real result string inside its script and ships
only the placeholder its markup started with — CAM-ICU's reads "select all four
features to calculate", which is wrong in every state the form can reach.
findScriptComputedText identifies those elements so the placeholder is dropped
rather than emitted as a label, and the outcomes are rebuilt from the rule the
form states in prose, as one Label per case gated by a root-scope condition.
Conditions are inferred, not extracted — in both cases. Scripts are read for
the toggle or write target only, never for logic and never executed. What
reveals a section, and what each outcome means, comes from the form's own visible
instructions. The outcome wording is the converter's, since the source's lived
in the script, so it ships with an UNCLEAR_LABEL warning. On a stepwise or
scored form, check the rules and the outcome text in review.
Anything else with display:none is removed and reported exactly as before.
4. Scripted behaviour — declarative config now converts, on request
Conversion captures fields. What the mock-up's JavaScript does with them splits into two halves, and only one of them is recoverable without running the page.
Declarative config — now converts (#75), when the uploader ticks "Read option lists from this mock-up's scripts":
- option lists that never appear in the markup (the insulin-type dropdown)
- option cascades (interventions scoped to the GRBS glycaemia category)
- thresholds (the <40 / 40–53 / 54–70 / 71–180 / >180 reference bands)
- score → stage → description reference tables (VIP's
vipRefFull)
The scripts are parsed, never executed, and only whole literal values are taken — see Reading config from scripts for the parser's rules, its caps, and why this is not a reversal of the strip-scripts default. Default is off; everything read is named in a conversion warning, and the opt-in itself is audit-logged.
Imperative behaviour — still not converted:
- computed fields ("Intervention (auto)", day numbers derived from an insertion date) — these are functions, and a function body is never a literal
- cross-field enable/disable driven by code paths rather than a config table
Add those in the designer after conversion. Conditional visibility is a separate case and already converts — see limitation 3.
5. Click-built content and unmeasured group boundaries — resolved
Was: the sandboxed render waited for load plus a short settle and read the DOM once. Fields that only existed after an interaction were absent. And in a transposed matrix, which rows repeat per sub-record (VIP: per Day vs per Cannula) was inferred by the model from what the labels mean — the mock-up knew the answer, because pressing "+ Day" adds cells to exactly the day-level rows, but the render never pressed it.
Now (#76): a bounded probe inside the same sandbox presses each add-control once and re-measures. Two things follow:
- Content that only exists after a click converts. The post-probe DOM is read when it is richer than the pre-click one. A wound-assessment mock-up that renders nothing until "+ Add wound site" is pressed goes from 0 fields (rejected as "not a form") to a converted form.
- The nested-group split is measured, not guessed. Pressing "+ Day" on the VIP chart grows exactly 14 of its 22 rows, so those 14 are the day-level group and the other 8 belong to the cannula. The hint now says so, and the prompt tells the model not to move a row between levels.
See Pressing the page
for the bounds and the failure behaviour. In short: only add-affordance controls
are pressed, once each, at most three; the probe has its own budget inside the
render timeout; dialogs are dismissed; and a probe that times out or crashes
yields the un-probed result rather than a failure. HTML_PROBE_DISABLED=1
switches it off without giving up rendering.
Residual limits: only add-affordances are pressed, so content behind a tab, an accordion or a "Show details" toggle is still missed. And a control is pressed once — a structure that only appears on the second press is not seen.
6–7. Deliberate limits
- Size caps (160 fields, 160 table rows, 32k chars, 2 MB) exist because one
conversion pass must emit the whole Data/UI/Print schema set; past the cap the
model runs out of output budget and truncates silently, which is worse than
a clear rejection. The caps and the output budget move together — see
PDF-TO-FORM. Split big forms
into one file per section. All but the file-size cap are deployment-level
env vars (
CONVERSION_MAX_FIELDS,CONVERSION_MAX_TABLE_ROWS,CONVERSION_MAX_TOKENS,CONVERSION_MAX_SOURCE_CHARS— defaults 160 / 160 / 40 960 / 32 000): an operator whose providers can emit more (OpenAI GPT-5-family, Claude) may raise them together. They are deliberately not per-user settings — the bound is what one model pass can reliably produce, not a fairness quota — and raising them past a smaller provider's output ceiling (Kimi, Minimax, Ollama) makes conversions on that provider fail or truncate — such a deployment lowersCONVERSION_MAX_TOKENSinstead. A repeating log counts as the singlerecordTableit becomes rather than as its rendered rows, so a chart the page fills with blank rows at load is not rejected for rows the model never has to emit. - Structural, not pixel, fidelity on screen is a stance, not a gap: the renderer is a responsive data-entry engine. Paper-accurate output is the print engine's job, reconstructing A4 from the Print Schema.
What already degrades gracefully
Worth knowing when assessing a bad conversion — these safety nets are in place:
- Unconfigured arrays never render as the stock widget. If the model emits
an array of objects without
recordTableconfig, columns are derived from the item schema and it renders as a usable table (deriveRecordColumnsin form-core). Degraded, not broken. - Errors name their cause. A rejected upload distinguishes "no browser in this deployment" / "rendering disabled" / "the page built nothing" — each points at whoever can actually fix it.
- Nothing is dropped silently. Uncertain or unrecoverable elements become conversion warnings on the job, surfaced in review.
- Review is the backstop. Every conversion lands as a REVIEW-status draft; "Refine with AI" fixes what the pipeline got wrong before anything is published.
Triaging a bad conversion
Ask one question first: did the detector miss the shape, or did the model ignore a hint it was given? The conversion warnings answer it — a detected structure is named in the hints; its absence means detection missed. The two have different fixes (extractor work vs prompt work), and issues filed against the wrong one don't get better.