How an uploaded PDF, image or HTML mock-up is converted into a JSON Forms schema, and what the converter can and cannot recover.
PDF to Form Conversion
OpenMedForm converts paper-based clinical forms — PDFs, images, and HTML mock-ups — into JSON Forms definitions using AI.
Source files also include images and — for the jsonforms engine — HTML mock-ups (see HTML mock-ups below, which covers the mapping, the security model, and the size limits).
How It Works
- The author uploads a PDF, image, or HTML mock-up of a clinical form
- For a PDF, the backend extracts embedded text (
pdf-parse) and, whenpdftoppmis available, renders up to the first four pages to PNG - If the selected provider supports image input, those page images go to the model alongside the extracted text, so layout (tables, tick boxes, scoring grids) is read visually rather than guessed from text order
- For an HTML mock-up, the markup is cleaned to inert semantic HTML — see HTML mock-ups below
- The model emits the separated Data / UI / Print schemas plus translations
- The Data Schema is compile-checked under Ajv 2020-12; per-field confidence and warnings are recorded
- A draft form is created in
REVIEWstatus and the author reviews it, refines by prompt, and publishes
API
POST /api/conversions — multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file |
PDF / image / HTML | Yes | PDF, PNG, JPEG, WebP, GIF (max 10 MB) or HTML (max 2 MB) |
provider |
string | No | AI provider name (uses the tenant default if omitted) |
instructions |
string | No | Extra instructions for the conversion |
category |
string | No | Clinical category for the created form (max 100 chars). Absent leaves it null |
formType |
PATIENT | NON_PATIENT |
No | Absent leaves the schema default (PATIENT) |
Multipart carries no JSON types, so every field arrives as text and the DTO
parses accordingly. The body is validated with forbidNonWhitelisted: a field
that is not in the table above is a 400.
Returns the created conversion_job; poll GET /api/conversions/:id for
PENDING → RUNNING → REVIEW | FAILED. On success the job carries the formId
of the draft. POST /api/conversions/:id/accept promotes it REVIEW → DRAFT.
To create a form with no source document, describe it instead:
POST /api/forms/from-prompt with { name, prompt, category?, formType? }. It
runs the same generator and assembler, synchronously, and returns the draft form.
Form metadata is the same on both routes
category and formType are properties of the form, not of the source
document, so both entry points collect and persist them identically — a form
must not end up with thinner metadata for having been uploaded rather than
described. The web dialogs make both required in practice: the Patient /
Non-Patient picker (shared component, defaulting to PATIENT) and the category
dropdown are shown on the file dialog as well as the describe dialog, and
Generate Form stays disabled until a category is chosen.
They are optional on the wire so a direct API client can still convert with nothing but a file. When either is omitted the column keeps its null / schema default rather than being overwritten with an empty choice.
Note that there is currently no post-creation UI for editing category or form
type — the forms list and the preview page do not surface them, only
PUT /api/forms/:id does. That is why the dialog asks up front instead of
letting the author adjust after review.
Vision Support
When pdftoppm is available, the backend renders up to the first four pages of
the PDF to PNG and sends those images alongside extracted text to providers with
image-input support. Each supplied page acts as its own visual reference: the
conversion uses parallel columns only where the source has them, and keeps wide
tables, grids and narrative areas full-width where the source does.
The page images are also used for a structure pre-pass before the conversion itself — see Structure hints for PDFs and images.
Providers with page-image vision support: Claude, OpenAI. Text-only fallback: Ollama, Minimax, Kimi.
Deployment note: install Poppler (pdftoppm) in API runtime images to enable
page-image vision. Without it, conversion falls back to embedded text.
Clinical Component Mapping
- Observation charts, EWS/NEWS charts and ward multi-parameter logs map to
vitalSignsChart - Static EWS/NEWS reference ranges and escalation protocols map to
clinicalReferenceTableand staticLabelelements - Patient-identity header fields are ignored by default — patient context is supplied outside the form schema
Specialized Prompt
prompts/pdf-to-jsonforms-prompt.ts is specialized for clinical form
digitization. It instructs the model to identify sections, field types and
scoring logic; map paper elements to the omf control vocabulary; derive
camelCase keys from labels; keep the source-language label text verbatim; and
surface anything uncertain as a warning rather than dropping it.
UI Flow
The forms list has From File (upload a PDF/image/HTML) and From Prompt (describe the form). Both create a draft and open it on the preview page, where "Refine with AI" edits it and "Publish" makes it available for data entry. There is no drag-and-drop builder — see ADR-004.
Both dialogs ask for Form Type (shared FormTypeSelect, defaulting to
Patient) and Category (shared CategorySelect) so the two doors produce the
same entity — see Form metadata is the same on both
routes. The file dialog additionally
takes the form name from the file name, which is the one field the two
routes still collect differently; it is renameable after review.
Conversion Pipeline
Each run is tracked as a conversion_job. The pipeline emits the separated
Data / UI / Print schemas + translations via a dedicated system prompt
(prompts/pdf-to-jsonforms-prompt.ts). The Data Schema is verified to compile
under Ajv 2020-12 before persisting. The UI layout is inferred from the supplied
source pages rather than a fixed clinical template, and per-field confidence
and warnings are stored in conversion_warning so uncertain elements are
surfaced for review, never silently dropped.
Jobs run in the background (lightweight fire-and-forget — no external queue) and
transition PENDING → RUNNING → REVIEW | FAILED; poll GET /api/conversions/:id.
While RUNNING the job also reports stage — READING_SOURCE → GENERATING →
VALIDATING → SAVING, with a human stageDetail such as 3 pages · claude —
which the upload dialog renders as a live checklist with an elapsed timer.
Deliberately no percentage: most of the wall time is a single LLM call of
unpredictable length, so a 0–100% bar would stall near the top and read as
hung. Stage writes are best-effort; losing one never fails the conversion.
A successful job creates a draft form in REVIEW status; ai.convert is
audit-logged.
Scored clinical checklists (colour-coded domain boxes)
Many clinical forms group tick-box risk factors into coloured, icon-headed
domain boxes where each row reads <risk factor> …… <points> (e.g. a VTE risk
assessment with AGE / CARDIOVASCULAR / SURGICAL boxes). The jsonforms conversion
reproduces these faithfully:
- Each domain box → a
Groupwhoselabelis the box header, carryingoptions.omf.accentColor(a hex approximating the box's border/header colour),options.omf.icon(an emoji matching the box's pictogram, e.g.❤️), andoptions.omf.pointLegend(the distinct point values shown as header chips). - Each row → a boolean
Controlwhose Data Schematitleis the exact source-language label, carryingoptions.omf.points(the printed point value). The renderer draws the checkbox on the left, the label, and a colour-coded point badge on the right (1→blue, 2→green, 3→amber, 5→red).
Scored single-selects (Morse, Braden, GCS)
Bedside instruments usually score differently: one dropdown or one set of
mutually-exclusive radios per item, where the choice carries the points.
These convert to a single enum Control with the points in
options.omf.optionPoints, keyed by the stored code, and the option text in the
dataSchema's oneOf titles:
{ "type": "Control", "scope": "#/properties/morse/properties/ambulatoryAid",
"options": { "omf": { "control": "radio", "optionPoints": {
"NONE_BEDREST_NURSE_ASSIST": 0, "CRUTCHES_CANE_WALKER": 15, "FURNITURE": 30 } } } }
The prompt forbids folding the number into the code (FURNITURE_30). Before
optionPoints existed a generator had no other way to record it, which produced
forms whose total never moved off zero and whose options read as YES_25 on
screen. An existing form in that state is repaired by a prompt-designer refine —
the refine prompt rewrites such codes, moves the numbers into optionPoints, and
adds the oneOf titles.
Every row is extracted as its own field — the prompt explicitly forbids emitting
an empty scoringMatrix (which would drop the risk factors). Saved data is a set
of clean booleans. A YES/NO (or question-then-answer) row where the paper
prints the options to the right of the label renders label-left / options-right:
set options.omf.screen.labelPosition: "left" (a two-option radio defaults to
this). The section icon belongs only in options.omf.icon, never also prepended
to the Group label (the renderer de-duplicates a doubled glyph defensively).
Nested groupings are preserved, not flattened: a heading that introduces an
indented sub-list (e.g. "Immobility … PLUS one or more of:" followed by its
dependent factors) becomes a nested Group with options.omf.variant: "subsection" — an indented sub-heading with its items nested beneath it (no
box). A heading line with no options printed beside it is a Label or a
subsection heading and never receives its own input. The same accentColor / icon / points extensions render
identically in the React and Angular renderers via the shared design tokens and
point-value palette. These extensions live under options.omf in
packages/form-schema-types.
Periodic / reassessment matrix
A grid where each row is an item and each column a repeated period — e.g.
a Nursing Diagnosis × Day 1–5 reassessment table with a checkbox in every cell —
is a single omf.control: "checklistMatrix" control (not dozens of scattered
booleans). Config rides on options.omf.rows / options.omf.columns
({ key, label }[]); the control binds to an object property and stores a
nested value { [rowKey]: { [colKey]: true } }. One compact control keeps the
conversion output small (which helps the model finish dense multi-page forms) and
renders the same scrollable grid in React and Angular.
Multi-page completeness
Conversion extracts every section and option across all pages — side-by-side
boxes (e.g. Anticoagulant + SCD contraindications, or Mechanical +
Pharmacologic orders) become a HorizontalLayout of two fully-populated Groups,
and long medication lists keep every row (with dose/qualifier text in the label).
A section that is only a header band on the paper is never emitted empty. Because
completeness on dense forms ultimately depends on the LLM, anything the model is
unsure it captured is surfaced as a POTENTIAL_MISSING_FIELD warning in review
rather than silently dropped — re-run or refine if a section still looks thin.
The Data Schema must compile under Ajv or the job fails. One common LLM slip —
a local $ref to a $defs entry it never defined (e.g. #/$defs/age) — would
otherwise reject the whole schema; the assembler instead strips the dangling
$ref (the field then validates permissively, keeping any sibling keywords)
and records an UNCERTAIN_FIELD_BINDING warning so the reviewer can tighten it,
so one bad reference no longer sinks an otherwise-complete conversion.
The assembler also validates every omf.control against the config it draws
from. A config-driven control emitted without it (a checklistMatrix with no
omf.rows/omf.columns, a scoringMatrix with no or empty domains, an
unconfigured vitalSignsChart / colorCodedGrid / clinicalReferenceTable /
recordTable) would render an empty shell and silently lose the paper's
content; an enum-driven radio/checkboxGroup bound to a schema with no
enum/oneOf would render no inputs. Each such control gets a
MISSING_CONTROL_CONFIG warning against its binding — a warning, not a
rejection, so one half-configured control never sinks the whole form.
Total score & risk stratification
Serial observation forms
A vitals or observation chart, a fluid balance sheet, a pain reassessment — anything the source
shows being filled every few hours or each shift for the same patient (a time-column grid, "q2h",
"4-hourly") — is converted so the clinician sees the patient's previous readings while charting.
The model declares options.omf.history: { show: 'inline' } once, on the section's Group; every
measurement Control inside inherits it, individual fields narrow it (popover) or opt out (none)
(ADR-006). Numeric measurements get their UCUM omf.unit, and a
"Time of observation" field is marked omf.effectiveAt: true. One-off sections (admission details,
consent, signatures) and single-occasion assessment scores do not get history. The refine chat treats
"show previous values on the Observations section" as one Group-level patch, and the Dictionary panel
shows and edits the result (Clinical Terminology).
Many scored forms sum the ticked points across every box into a grand total and
map that total to a risk level. This is data-driven and computed from a single
source of truth — the options.omf.points on each checkbox, and
options.omf.optionPoints on each scored single-select:
- Live, on screen (clinician aid): each scoring box header shows a running
section subtotal (
Σ N) — the innermost scoring Group only, unless a Group setsoptions.omf.showSectionTotal(force it on an outer box the source totals) oroptions.omf.hideSectionTotal(remove it) — and aomf.control: "scoreSummary"element shows the grand total, the per-section breakdown, and the risk band. The renderers derive this withcollectScoreItems/computeScorefrom@openmedform/form-coreas the clinician ticks boxes. Risk bands ride on the scoreSummary underoptions.omf.bands({ minScore?, maxScore?, label, color }, both bounds inclusive). - Authoritative (stored): at conversion (and again on every prompt-designer
refine) the backend derives
form_version.scoring_rulesfrom the sameomf.points/omf.optionPoints— asumrule over each scored field's data path (option-priced fields carry their map instead of a single number), plus athresholdrule from the bands. OnPOST /submissions/:id/completetheScoringServicerecomputes the total and risk level from the saved data and storessubmission.scores/submission.risk_level. Client totals are never trusted (Form Engine Rules); the on-screen figure is display only.
Because the live aid and the stored rules both read the same omf.points, they
cannot drift — and re-deriving on refine keeps scoring correct when a reviewer
adds or removes scored items.
HTML mock-ups
POST /api/conversions also accepts an HTML mock-up (text/html) — the kind
an author now commonly produces with an LLM before building the real form. HTML
is structurally richer than a PDF for this purpose: a PDF needs vision to infer
layout, whereas HTML declares it, so conversion runs text-only and still
recovers grouping and tables reliably. Mapping:
| Source markup | Produces |
|---|---|
<fieldset>/<legend>, <section> + heading |
a Group labelled with that heading |
<table> of label rows × repeated columns |
checklistMatrix (rows/columns from <th>/<td>) |
<table> with a <thead> |
OmfTableLayout + options.omf.columns (a real grid) |
| left-label / right-value grid (no header row) | OmfTableLayout + OmfTableRow |
<input type="checkbox"> |
a boolean Control |
radio group / <select> |
an enum Control (omf.control: "radio") |
| compact same-line choose-any checkbox row | omf.control: "checkboxGroup" (array of coded oneOf options) |
<label for=…> / adjacent text |
the dataSchema property title |
colour utilities or inline colour (bg-red-50, color:#c0392b) |
options.omf.accentColor |
| leading emoji in a heading | options.omf.icon |
| trailing number on a scored row | options.omf.points |
Column tables
A source table that has a header row (an HTML <thead>, or a paper table
with a heading row like Role | Name | Signature | Date) converts to an
OmfTableLayout carrying options.omf.columns:
{ "type": "OmfTableLayout",
"options": { "omf": { "columns": [
{ "label": "#", "width": "40px", "align": "center" }, { "label": "Item" },
{ "label": "Status", "width": "150px" }, { "label": "Date & Time" }
] } },
"elements": [
{ "type": "OmfTableRow", "label": "1", "elements": [
{ "type": "Label", "text": "Ensure after care is discussed and organised" },
{ "type": "Control", "scope": "#/properties/aftercare/properties/item1Status" },
{ "type": "Control", "scope": "#/properties/aftercare/properties/item1At" }
] }
] }
The renderer then draws a real grid: a header row, and one cell per child
aligned under its column. A row's first column is the OmfTableRow label when
that cell is static text (Doctor, a row number); every other column is one
entry in elements — a Label for a static cell, a Control for an input.
elements.length + (label ? 1 : 0) must equal columns.length.
Cell controls do not repeat their own label — the column header already names them — so a sign-off grid does not show "Name / Signature / Date" again in every row. Source widths and alignment carry over, and a wide table scrolls inside its own container instead of pushing the host page sideways.
Omitting columns keeps the two-cell left-label layout (shaded row label |
contents) used by grids without a header row.
Security model
The upload is untrusted and is handled as inert text only — see
html-extract.ts:
- Never rendered or executed. No headless browser is involved, so there is no script-execution surface.
- No network access.
src/href/srcset/@importare dropped rather than resolved, so there is no SSRF (including cloud-metadata endpoints) and nofile://read surface. - No XXE. Parsed with a lenient HTML parser, never an XML parser.
- Allow-list, not deny-list. Only known-safe elements and attributes survive,
so
on*handlers,formaction, embeds and anything new are dropped by default.class/styleare kept deliberately — they carry the section accent colours. - Hidden content is removed (
display:none,visibility:hidden,hidden,aria-hidden,font-size:0, Tailwindhidden, HTML comments). This is the natural place to smuggle instructions past the person uploading the file and into the LLM, so it is stripped — and the removal is reported as a conversion warning rather than happening silently. (sr-onlyis kept: it is real accessible text, not smuggled content.) Two exceptions are carved out for content the page itself reveals — see Conditional fields and Progressive disclosure for the exact shapes and the limits that keep each one small. - The prompt additionally frames the markup as untrusted source material to be read for layout only.
Downstream, extracted strings only ever become JSON schema values: the React and
Angular renderers escape by default (no innerHTML anywhere) and the print
engine escapes explicitly, so this text never re-enters an HTML context.
Sections built by JavaScript
Only what is in the markup can be converted by the static path. (A mock-up that builds its form at load is rendered first — see Mock-ups that build their form with JavaScript — but a container nothing ever fills stays empty either way.) Mock-ups generated by an LLM often render their option lists from a JS array, leaving an empty container behind:
<h3>Care Categories</h3>
<div class="hint">Select all categories that apply to this patient.</div>
<div class="multiselect" id="ms-comfort-categories"></div>
<script>/* fills #ms-comfort-categories at runtime */</script>
A heading and a hint next to an empty box is exactly where a model will invent a
plausible-looking control — in one real case an Add to Care Categories array
widget that appears nowhere on the source form. Two things prevent that:
extractFormHtmlrunsfindScriptFilledPlaceholders()before scripts are stripped and returnsscriptFilledPlaceholders(named-but-empty containers in a document that ships scripts). They are reported as a conversion warning listing each container, so the reviewer knows to add those fields by hand. A document with no<script>never triggers this, so ordinary spacerdivs in a static mock-up stay quiet.- The prompt forbids inventing fields for an empty section — the heading is
emitted as a
Labelplus aPOTENTIAL_MISSING_FIELDwarning — and restrictstype: "array"to genuinely add/remove-able lists, so a "select all that apply" group becomes one boolean Control per option rather than an array.
To convert such a section, inline the options as real markup (or paste the rendered DOM) before uploading.
Repeating logs (recordTable)
Not every empty container is a lost cause. The common clinical counterpart is a table the user adds rows to, where the row is only a summary and the record's real fields live behind it — a treatment day, a medication round, an observation entry. In a mock-up it looks like this:
<div class="label-tag">0 treatment days logged this month</div>
<button onclick="cx_addSession()">+ Add treatment day</button>
<table>
<thead><tr><th>Day</th><th>Date</th><th>Cycle / Day#</th>…</tr></thead>
<tbody id="cx_tbody"></tbody> <!-- filled by script -->
</table>
The <tbody> is empty for exactly the same reason as a JS-built section, but
this one is recoverable: the <thead> names every column and the button
names the thing being added. findRepeatingTables() picks that up before scripts
are stripped and returns the columns, the add label and the count line, which the
conversion prompt passes to the model verbatim. An empty <tbody> under a
populated <thead> is therefore excluded from scriptFilledPlaceholders —
warning about it would throw away a whole treatment-day log.
A repeating log requires an add affordance (Add … / New …); a print-only grid
with an empty body and no button is left as a plain table, since a clinician
fills that by hand.
It converts to a single array Control:
{
"type": "Control",
"scope": "#/properties/treatmentDays",
"options": {
"omf": {
"control": "recordTable",
"recordTable": {
"addLabel": "+ Add treatment day",
"countLabel": "{n} treatment day{s} logged this month",
"emptyLabel": "No treatment days logged for this month yet.",
"columns": [
{ "label": "Date", "path": "date" },
{ "label": "Cycle / Day#", "path": "timelog.cycle", "pairWith": "timelog.dayNum" },
{ "label": "Adverse events", "countOf": "adverseEvents", "align": "center" }
]
}
},
"detail": { "type": "OmfTabsLayout", "elements": [ /* one Group per stage */ ] }
}
}
columns[].pathis a dot path inside one record;pairWithrenders a combinedA / Bheader, andcountOfcounts a nested array.countLabelsubstitutes{n}for the count and{s}for the plurals.options.detailis the standard JSON Forms per-item UI schema. Use anOmfTabsLayoutwhen a record has more than ~15 fields — the source Chemotherapy Monitoring record has around 100 across eight stages, and one scrolling panel is unusable at the bedside.- Record tables nest: a treatment day's Drug Administration tab is itself a
recordTableoverdrugs, and the parent's "Drugs" column counts it.
Both renderers implement this identically, and the summary-cell derivation lives
once in @openmedform/form-core
(record-table/summary.ts)
rather than being duplicated per framework — a log that showed a different date
or adverse-event count in the EMR than in the web preview would be a clinical
safety problem, not a cosmetic one.
An array can no longer fall through to the stock list widget. Any
array-of-objects control without omf config still renders as a record table,
with summary columns derived from the leading scalar properties of the item
schema. A derived table is imperfect; the stock "Add to … / Items / Valid / No
data" widget is unusable on a clinical form, so it is now unreachable in both
renderers.
Matrix (transposed) tables
The other way a repeating record gets drawn: fields down the side, record instances across the top. The NH VIP cannula chart is the canonical case —
| Parameter | Cannula 1 [+ Day] |
| Date of Insertion | … |
| Site | … |
| Size of Cannula (Gauge) | … |
This is the transpose of a repeating log, and it
converts to the same thing: one recordTable. The row labels are the item
schema's fields; each column heading is an instance of it.
findTransposedMatrices() recognises the shape — a header row of 2+ cells over
body rows whose first cell is plain label text — and passes the model the full
row-label list, the instance headings, and both add-controls. Without that hint
the model reliably fails in three ways at once, all observed on the real VIP
form: it turns "Cannula 1" into a column, drops the per-instance fields, and
leaves the nested day-group unconfigured.
An instance name is never a field and never a column. "Cannula 1", "Patient 2", "Visit A" identify which record you are looking at.
A + … control inside a column heading (the chart's + Day) means each record
carries its own nested repeating group, so the item schema gets a nested
array with its own recordTable config.
The detector deliberately ignores: tables whose first column also holds inputs (an ordinary data grid), tables with no inputs at all (a score legend or dosing reference), anything under three rows, and — because a matrix is a record repeated across columns — a table with a single answer column and no control that adds another. "Parameter | Patient's Condition" is a checklist: read as a matrix, its 14 rows become the fields of one record and the answer column becomes an instance you can add more of.
Rows are edited in place
A summary column that names one concrete field renders that field's real
control in the cell — date picker, select, number — so a row is filled in
exactly as on the source grid. Derived columns stay read-only text, because they
have no single value to write back: countOf counts a nested array, pairWith
merges two fields into one cell.
Two consequences worth knowing:
- The actions column is pinned. A converted chart can run to ten columns and
scroll sideways. If
Open/ remove scroll out of view, a row becomes not just hard to edit but impossible to delete — there is no other affordance. Openis hidden when there is nothing behind it. If every field of the record is already a column — a blood-sugar row, say — a detail panel would be empty, so the button is omitted. Records with more fields than columns keep it, and the detail panel remains the place for the rest.
This is what makes a nine-column observation chart usable: all nine cells are live, and the row still deletes. A treatment day with ~100 fields keeps its tabbed panel; only its handful of summary columns are inline.
Column orientation
Set omf.recordTable.orientation: "columns" to render records the way the paper
draws them — field labels down the left, one column per record, growing
sideways — with instanceLabel supplying the noun:
"recordTable": {
"orientation": "columns",
"instanceLabel": "Cannula", // heads each column: "Cannula 1", "Cannula 2"
"addLabel": "+ Add Cannula"
}
Both orientations store identical data; this is purely a fidelity choice.
Default is rows, which suits a chronological log that grows downward. Use
columns when the source compares instances side by side. The expanded detail
panel still spans the full width in either mode, because a record's whole field
set never fits in one column.
Mock-ups that build their form with JavaScript
An LLM-generated mock-up routinely builds its form at runtime from a config array. The real NH Visual Infusion Phlebitis (VIP) form is the extreme case — its entire markup is:
<table id="tbl-vip">
<thead><tr id="vip-head-row"><th>Parameter</th></tr></thead>
<tbody id="vip-body"></tbody>
</table>
addCannula() builds 22 rows and 23 fields at load. Statically there is nothing
to convert.
Conversion renders these itself. A mock-up is executed in a sandboxed headless browser, and the resulting DOM read instead, in two cases:
- it ships scripts and either has no fields at all, contains named-but-empty containers — the case below — or contains a script-populated container the markup does not otherwise describe (see A full form that still hides a checklist); or
- its markup yields no repeating structure but it names an "Add …" control, in which case the render exists to measure the layout — see Grids built without tables.
See html-render.ts.
Rendering is not trusting:
- Chromium's own OS-level sandbox contains the page. Uploaded script is never evaluated in the API process. (jsdom was rejected for exactly this: it would run untrusted code inside Node.)
- No network. The context is
offlineand every request is aborted by a catch-all route, and content is injected withsetContentrather than navigated to — so there is no origin to fetch from. Verified against a hostile page attempting169.254.169.254metadata,file:///etc/passwdand an external exfil URL: all three blocked, nothing leaked into the DOM. - Bounded. 30s wall-clock cap (it must cover a cold browser launch, not
just the page), with a separate 8s budget for
interaction probing inside it;
downloads refused, pop-ups closed unread,
context always torn down. A
while(true)script costs one timeout and the renderer returns null. - The output is re-sanitised. The rendered DOM goes back through the same
extractFormHtml: scripts stripped, attribute allow-list enforced, hidden content removed. Rendering widens what can be read, never what reaches the model.
Rendering is optional. If no browser is installed, or HTML_RENDER_DISABLED=1
is set, conversion falls back to the static markup — and the rejection says
which happened, because the advice differs completely:
| Message | Cause | Who fixes it |
|---|---|---|
| "no headless browser is available in this deployment" | Chromium missing — a bare npm run start:dev, or an image built before it was added |
Operator: rebuild the API image, or set CHROMIUM_PATH |
"automatic rendering is switched off (HTML_RENDER_DISABLED=1)" |
Deliberately disabled | Operator, if unintended |
| "rendered but produced no form fields" | The page ran and genuinely built nothing at load | Author: the form may need a click first |
The first is an installation problem, not a problem with the uploaded file, and
the message says so. The server also logs a warning once per process when a
render is needed and no browser can be launched. Local dev and CI need no Chromium; the API image
installs the Alpine chromium package and points playwright-core at it via
CHROMIUM_PATH.
A render only replaces the static read when it recovers more fields, so a script that errors halfway cannot lose content that was already readable. When it does help, a conversion warning records how many fields it recovered.
A full form that still hides a checklist
A page does not have to be entirely script-built to lose most of a section.
The Sepsis Screening & Monitoring sheet carries 66 static fields — and writes
its 14 "Clinical Suspicion of Sepsis" parameters into an empty <tbody> from a
JS array. With fields in the markup, the render trigger never fired, and the
whole checklist reached the model as its two column headings; the converted
form showed one line of static text where the parameters belonged.
findScriptPopulatedContainers() closes that gap. It names an empty container
whose id or class the page's own scripts mention, in a document whose scripts
build DOM at all (appendChild, innerHTML = , insertRow, …) — and
excludes the shape the markup already describes: an empty <tbody> under a
populated <thead> beside an "Add …" control is a
repeating log, convertible without a render.
Anything left is a container whose contents exist only once the page has run,
so the render is spent and the recovered rows go through the normal sanitiser.
Two consequences follow:
- Static markup hints survive the render. Running the page can erase the
evidence a hint was read from — the hourly-observation
<tbody>detected as arecordTablebecause it was empty comes back with three blank rows in it. The repeating-structure hints from the static parse are kept whenever the rendered pass shows fewer, so a render never trades a whole chart for the rows it recovered elsewhere. - An unrecovered container is named. If no browser is available, or the page
builds nothing, the containers are listed in a conversion warning like
findScriptFilledPlaceholders' — an acknowledged gap, never an invented control.
A related correction sits in the matrix detector: a two-column "Parameter | Patient's Condition" table is a checklist, not a matrix chart. A matrix needs a record repeated across columns, so two or more instance columns, or a control that adds one, is now required — otherwise a 14-row screening checklist converted into an "add another patient's condition" grid.
What this does and does not fix
Measured against three real mock-ups:
| Mock-up | Static | Rendered | Outcome |
|---|---|---|---|
| VIP (entire form script-built) | 0 fields | 21 fields | recovered; converts normally |
| Comfort Care (empty named containers) | 25 fields | 25 fields | unchanged — see below |
| Chemotherapy Monitoring (repeating log) | 6 fields | not rendered | handled by Repeating logs |
Comfort Care does not improve, and should not. Its script contains no
reference to #ms-comfort-categories or #comfort-care-body at all — that
section's markup was pasted in without its builder code, so there is nothing to
render. The
Sections built by JavaScript warning remains
the correct outcome: an acknowledged gap rather than an invented control.
Chemotherapy Monitoring is deliberately not rendered. It has readable static
fields and a recoverable repeating table, so it does not meet the render
trigger. Rendering it would produce one cannula's worth of inputs as a flat
table and lose the add/remove semantics that recordTable reconstructs from the
<thead> and the "+ Add Cannula" button.
Two things still worth checking after a render:
- Conditional fields. A "Please specify…" input that only appears when a select is set to "Other" is kept and converted with a SHOW rule — VIP yields all 23 fields. See Conditional fields.
- Progressive disclosure. A whole section the page's own script reveals is
also kept, and the model is told to gate it with a rule. The reveal
condition is read from the form's visible instructions, never from the
script, so check that each gated section appears on the right answer. See
Progressive disclosure.
Any other
display:nonecontent is still stripped, so a conditional block matching neither pattern must be revealed before upload. - Row count. A repeating table renders however many rows the script created on load, plus one more per add-control the probe pressed — see Pressing the page.
Conditional "Other → Please specify…" fields
Hidden content is stripped because it is the natural prompt-injection channel.
But mock-ups also use display:none for real fields:
<select>
<option>Forearm</option>
<option>Other</option>
</select>
<input type="text" placeholder="Please specify…" style="display:none">
That input is genuine data capture — the VIP chart has two of them, and stripping
them cost it 2 of its 23 fields. The platform already renders conditional
visibility (form-core evaluates JSON Forms rules), so the fix is to emit the
rule rather than to keep or drop the field blindly.
findConditionalFields() runs before the hidden-content strip, spares the
field, and passes the model the pair. The converted Control carries:
{
"type": "Control",
"scope": "#/properties/siteOther",
"rule": {
"effect": "SHOW",
"condition": { "scope": "#/properties/site", "schema": { "const": "OTHER" } }
}
}
The const is the enum code the controlling property stores, not its display
label. A conditionally-shown field is never put in required — it is absent
whenever the condition is false.
Why this does not reopen the injection channel. What gets spared is deliberately tiny:
| Spared | Still stripped |
|---|---|
<input> of a text-entry type |
checkbox, radio, type="hidden" |
an empty <textarea> |
a <textarea> with content (it is not void — content is prose in disguise) |
| the field alone | any container, and the field too if a hidden ancestor is removed |
beside a <select> offering "Other"/"Others"/"Other (…)" |
a hidden field with no such partner |
| within that select's own parent | anything further away |
The only string a hidden element can newly put in front of the model is its own
label (placeholder / aria-label / title / name), capped at 60 characters — far
too small to hide an instruction, and the same class of string every visible
field already contributes. Every adversarial case above is covered by a test in
html-extract.test.ts.
Both renderers honour the rule. React reads JSON Forms' visible prop
throughout; the Angular renderer's controls read hidden, and its layouts, Label
and score summary get the same treatment from RuleAwareRenderer, which
evaluates the rule with the same form-core code the server uses. An
OmfTableRow is the one element the framework cannot resolve for itself — the
row is the layout and never reaches a dispatch — so both table renderers
resolve their rows through form-core's filterVisibleElements(). See
Conditional rows in a table.
Progressive disclosure: script-revealed sections
The carve-out above spares one input. Clinical worksheets also hide whole sections — a stepwise assessment that only asks the next question when the previous answer requires it. The CAM-ICU worksheet is the canonical shape:
<tr><td>Feature 1: Acute Onset…</td><td><select class="cam-feat">…</select></td></tr>
<tr id="cam-row-2" style="display:none">
<td>Feature 2: Inattention…</td><td><select class="cam-feat">…</select></td>
</tr>
findConditionalFields() cannot help here — it spares a lone text input, never a
container — so a four-feature delirium assessment converted to a one-question
form. Rendering the page does not recover it either: the script runs on load and
leaves the rows hidden, and the interaction probe
only presses add-controls, never a <select>.
findScriptToggledSections() runs before the strip, resolves the elements
the page's own script toggles the visibility of, and spares those. The hiding
declarations are removed from the spared element (present and marked invisible
reads to the model as "ignore this"), and each section is named in a conversion
warning.
The model is then told to gate them. On an OmfTableLayout the rule goes on the
row, not on the Controls inside it, so the row appears as a unit:
{
"type": "OmfTableRow",
"label": "Feature 2: Inattention",
"elements": [{ "type": "Control", "scope": "#/properties/feature2" }],
"rule": {
"effect": "SHOW",
"condition": { "scope": "#/properties/feature1", "schema": { "const": "PRESENT" } }
}
}
The condition is inferred, not extracted. Scripts are parsed for the toggle
target only — never executed, and never read for logic. What reveals a section
comes from the form's own visible instructions ("CAM-ICU is POSITIVE only if
Feature 1 is present AND…"). Where the source does not say, the model is
instructed to emit the section always-visible with an
UNCERTAIN_SECTION_BOUNDARY warning: always-visible is recoverable in review,
dropped is not. Check the rules on a converted stepwise form.
Why this does not reopen the injection channel. This is a real narrowing of the strip, so it is fenced in:
| Spared | Still stripped |
|---|---|
| a container whose visibility the script toggles by id or selector | a hidden container no script touches |
a section containing at least one <input>/<select>/<textarea> |
a hidden container of pure text — the injection shape |
| ≤ 1,500 characters per section, ≤ 6,000 per document, ≤ 12 sections | anything past those caps |
only when the page responds to a choice at all (a change/input handler) |
a page that toggles for some other reason |
A determined uploader can satisfy these conditions; they raise the bar, they are
not the boundary. The boundary is unchanged: extracted strings only ever become
JSON schema values, the whole subtree still passes the tag and attribute
allow-lists, and the prompt frames the markup as untrusted source material. Every
adversarial case in the table is covered by a test in html-extract.test.ts.
Computed result text (a banner the script writes)
Progressive disclosure recovers the questions. A worksheet that also computes an answer hides that somewhere the converter must not read — the script. CAM-ICU ships one placeholder in its markup:
<div id="cam-result" class="result-banner neutral">Overall result: select all four features to calculate</div>
while every real outcome is assigned at runtime (banner.textContent = 'Overall result: CAM-ICU POSITIVE (Delirium Present) …'). Emitted verbatim, that
placeholder becomes a label that is wrong in every state the form can reach
— this one even tells the nurse to answer four features on a form designed to
need three. Worse than a gap, because it reads as a real instruction.
findScriptComputedText() reports visible elements whose text a script writes,
resolved the same way as the visibility toggles and with the same hard limit:
the write target only, never the logic that produces the value. The element
must be visible and must contain no inputs — a script writing into a wrapper is
not a result banner, and treating it as one would cost the fields inside it.
This opens no injection channel at all: the elements are visible, so their text is already in the cleaned HTML. Nothing new reaches the model; the element is simply identified as computed.
Where the outcome comes from. Not the script — the form's own words. CAM-ICU states its rule in plain sight, in markup the model already receives:
CAM-ICU is POSITIVE only if Feature 1 is present AND Feature 2 is present AND (Feature 3 is present OR Feature 4 is present).
The model is told to drop the placeholder and rebuild that as one Label per
outcome, each gated by a root-scope condition — see
Computed outcomes for the shape and the two
rules that make it behave (required on every field the case depends on, and
mutually exclusive outcomes).
Which shape depends on what the verdict is computed from. A threshold on a
block of scored tick-boxes ("Positive if ≥ 2" under a qSOFA or SIRS list) is not
a Label rule — it is omf.bands on that block's Group, so the verdict rides on
the section's own subtotal. See
A verdict per instrument.
Rule-gated Labels are for a verdict computed from answers rather than a total,
or from a single typed number.
Three ways a script reaches its element, and conversion resolves all three, because one sheet routinely mixes them:
| In the script | Example |
|---|---|
| bound to a variable | const f = getElementById('lac6-flag') … f.textContent = |
| written inline | getElementById('qsofa-score').textContent = |
| passed to a helper | setFlag(getElementById('qsofa-flag'), score >= 2) |
The third case is why the Sepsis sheet's Positive/Negative chips were missed while its lactate flags converted: the write happens on the helper's parameter, which no lookup binds. Following the value into the callee would mean dataflow analysis of an untrusted script, so instead the element is noted as handed to a function, and that is trusted only when the same script writes element text somewhere.
Styling. Each outcome gets an omf.accentColor so it renders as the banner
the source drew rather than as body text — see
Callouts. The colour comes from
what the result means (positive/abnormal red, negative/normal green), because
a banner coloured through a stylesheet class cannot pass its colour on: <style>
is stripped as non-content, so the converter sees class="result-banner pos"
and never the declarations behind it. An inline style="color:#b3392c" does
convert.
The wording is the converter's, so it is flagged. The source's own phrasing
lived in the script and was never run, so the model composes the outcome text
from the form's title and rule sentence. That is a real judgement on a clinical
form, so it comes with an UNCLEAR_LABEL warning naming the result element, and
a conversion warning telling the reviewer to check it reads the way the unit
expects. Read the outcome labels before publishing a converted worksheet.
Reading config from scripts (opt-in)
Scripts are stripped before anything reaches the model. That is the right default for an untrusted upload — and it means conversion has been capturing the fields and none of the behaviour, because AI-generated mock-ups keep their most valuable clinical structure inside the script:
const glycaemiaCategories = [{ code: 'HYPO', max: 53, label: 'Hypoglycaemia' }, …];
const interventionsByCategory = { HYPO: ['15 g oral glucose', 'Recheck in 15 min'], … };
const insulinTypes = ['Regular (Actrapid)', 'NPH (Insulatard)', …];
None of that is in the markup. The <select> elements are built at runtime and
arrive empty, so the converted form gets the right fields with no options.
Opt in per upload — tick "Read option lists from this mock-up's scripts" in
the From File dialog, or send extractScriptConfig=true to
POST /api/conversions. Default is off. The choice is recorded in the
ai.convert audit entry.
script-config.ts then
parses the scripts with acorn and reads named literal bindings out of the
AST. What comes back is mapped to things the platform already has: enum options,
clinicalReferenceTable rows, scoring bands.
Parse is not execute. There is no eval, no Function, no VM and no
browser involved; nothing in the file runs. Beyond that:
| Rule | Effect |
|---|---|
| Literals only | A value is kept only if the whole subtree is string / number / boolean / null / array / object-of-literals. An identifier, call, member access, template hole, spread, getter or function anywhere in it discards the value whole — never half-salvaged, because half an option list is a list the form does not offer. |
| Named top-level bindings | const x = <literal> at the top level, or inside a top-level IIFE (how these mock-ups usually wrap themselves). Not arbitrary expressions, not nested scopes. |
| Config-shaped names | A name has to look like config (…Options, …Categories, …Types, …Table, …) and not like presentation (cssClasses, colors, apiUrl). A mock-up is full of literals that are not clinical config, and each one is prompt budget spent on noise. |
| Hard caps | 256 KB of script parsed, 40 entries, depth 6, 200 members per level, 300 characters per string, 12 000 characters total. |
| Still untrusted | The result is passed to the model as DATA under the same UNTRUSTED SOURCE MATERIAL framing as the markup. |
The <script> element itself is still removed from the cleaned HTML, opt-in or
not — reading config never puts executable text in front of the model.
Everything read is named in a conversion warning, so the reviewer can check any option list or threshold that came from a script rather than from the page.
A cascade becomes a documented dependency, not a guess. When an object's
keys are the values of another field, the model emits the union of the options
and a NEEDS_REVIEW / UNCERTAIN_FIELD_BINDING warning naming the dependency,
rather than inventing a rule the markup does not support.
Measured on the Blood Sugar (GRBS) fixture, same file both ways:
| Without opt-in | With opt-in | |
|---|---|---|
| Insulin type | no options | 6-value enum |
| Category | no options | 5-value enum (bands from glycaemiaCategories) |
| Intervention | no options | 10-value enum + a warning naming the Category dependency |
What this does not recover. Only declarative config. Computed fields ("Intervention (auto)") and code-path-driven enable/disable live in function bodies, and a function body is never a literal — those still belong in the designer.
Pressing the page (interaction probing)
Reading the rendered DOM once leaves two things invisible.
Content that only exists after a click. A mock-up whose fields are built by an "+ Add wound site" handler renders as an empty page: 0 fields, rejected as "not a form mock-up".
Which rows belong to the nested group. A matrix hint lists all 22 of the VIP chart's row labels, but nothing in the markup says that 8 of them are recorded once per cannula and 14 once per treatment day. The model had to infer that from what the labels mean — and the page knew the answer all along, because pressing "+ Day" adds a cell to exactly the day-level rows.
So the sandbox presses the page. After the normal render it clicks each
add-control once, re-measures the geometry, and hands both snapshots back;
rowsGainedBetween works out
which rows grew. As with everything else in the render, the browser side stays
dumb — click, re-measure — and the interpretation happens outside it, where it
is unit-testable without Chromium.
The measured split reaches the model as a statement of fact rather than a suggestion: "the control was actually pressed and these 14 rows are the ones that gained a cell, so they belong to the NESTED array and NOT to the outer record. Do not move a row between the two levels."
Bounds. This is interaction with an untrusted page, so it is fenced in:
| Bound | Why |
|---|---|
| Only controls matching the add-affordance patterns are pressed | Nothing that reads as submit / save / delete / print is ever touched. |
| Each control once; at most 3 in total | Pressing "+ Day" once is enough to learn which rows repeat; pressing it ten times only makes a bigger page. |
| The probe has its own budget (8 s) raced, not merely checked between clicks | A handler that spins blocks the page's JS, so the next measurement would otherwise sit there until the 30 s context timeout and double the worst case. |
| Dialogs are auto-dismissed | An alert()/confirm() cannot wedge it. |
| Same sandbox | Offline, every request aborted, no downloads, context torn down afterwards. |
| Additive only | A probe that times out, crashes, or measures all-or-no rows leaves the result exactly as it was. |
Measured against a spinning while(true) click handler: the probe gives up at
its budget, the render still returns usable HTML, and the whole call finishes in
~10 s — inside the 30 s cap.
HTML_PROBE_DISABLED=1 turns probing off without giving up rendering.
What it does not reach. 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, so a structure that only appears on the second press is not seen.
Structure hints for PDFs and images
Everything above reads structure out of HTML: markup for a <table>, rendered
geometry for a div grid. A PDF or image has neither — no DOM to parse, no
browser to render it in. Those uploads went to the model as page pictures plus
text, so a scanned cannula chart converted on prompt rules alone and landed less
reliably than the same form as HTML.
So the pages are asked directly, before the conversion, one narrow question: what repeating table structures are on them? The reply is a small, fixed shape
{ "tables": [ { "kind": "matrix" | "log", "page": 1,
"labelHeader": "Parameter",
"rowLabels": ["Date of Insertion", "Site", …],
"instanceHeaders": ["Cannula 1"],
"addLabel": "+ Add Cannula", "confidence": 0.9 } ] }
which is validated by
parseStructureProbe and
turned into the same REPEATING LOG: / MATRIX TABLE: paragraphs the HTML
detectors emit — the shared text lives in
structure-hint-text.ts,
so all three sources say the same thing.
Why a separate call rather than better conversion instructions. A narrow question with a checkable answer is far more reliable than the same judgement made in passing while generating a whole form — and its answer can be validated before anything depends on it. The pre-pass is also cheap: one small reply (4 096 tokens) against a 32 768-token conversion.
Every hint is discardable. The reply is model output derived from a document the uploader supplied, so it is treated like any other untrusted source. A malformed or hallucinated hint is worse than none, because the conversion treats hints as fact — so a reported table is dropped whole if:
| Condition | Why |
|---|---|
the reply does not parse, or has no tables array |
nothing to trust |
the model reported confidence below 0.5 |
it told us it was unsure |
| any label is missing, non-string, empty, or over 160 characters | a partial row list would build a record type silently missing fields |
| a matrix has fewer than 3 rows, or a log fewer than 2 columns | too small to be the shape |
| more than 8 tables, or more than 120 labels in one | not a form |
kind is anything else |
this pipeline has no hint for that shape; never coerced into one it does |
A probe that fails entirely is reported too, rather than silently yielding nothing — see the warnings below.
Not the same guarantee as HTML. A markup hint is something the extractor proved. A page hint is a careful reading of a picture, and it is introduced to the conversion as exactly that: "if the pages plainly show something different, follow the pages and add a NEEDS_REVIEW warning."
The warnings tell you which thing went wrong. A converted form with no record table has two very different causes, and they need different fixes:
| Warning | Cause | Fix |
|---|---|---|
| "No repeating table structure was detected on these pages…" | the probe found nothing (or could not run — it says so) | prompt/probe work; or upload the HTML mock-up |
| "…structures were detected on these pages, but the generated form contains no record table — the model diverged from the hint" | the hint was given and ignored | conversion-prompt work; fix this form in review |
Both are backed by conversionMetadata.structureProbe, written by the server —
{ source, detected[], rejected[] } — so a reviewer can see exactly what the
pipeline passed to the model rather than what the model says it received.
Measured on a PDF of the VIP cannula chart: the pre-pass read all 22 row labels
and the Cannula 1 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. On a plain patient-details PDF it correctly reported no tables rather
than inventing one.
What it does not do. It cannot press anything, so a PDF never gets the measured nested split that an interactive mock-up does — a matrix arrives as one flat record type. And it needs a vision-capable provider; a text-only provider skips the pre-pass entirely and converts as before.
Grids built without tables
Everything above depends on <table> markup. A mock-up that draws the same
chart with <div>s and CSS grid is, to a markup parser, an undifferentiated pile
of boxes — and AI-generated mock-ups increasingly draw them that way.
So detection does not rely on markup shape. The sandbox that already renders a
page also reports where every label, control and button landed — kind, own
text, and a scroll-adjusted bounding box — and
layout-detect.ts clusters
those boxes by y-coordinate into rows and by x-coordinate into columns. Where a
pixel sits does not care how the pixel got there, so the same shapes surface
whether the grid was a <table>, CSS grid, flexbox or absolute positioning.
The discriminator between the two shapes is what sits in the leftmost column below the header: static labels mean the fields run down the side and records run across (a matrix); controls mean each row is itself a record (a repeating log).
The output is the same RepeatingTableHint / TransposedMatrixHint the markup
detectors produce, so the prompt, the assembler and the renderers are unchanged.
Guard rails:
- Markup wins. Geometry runs only where markup detection found nothing. A
real
<table>states the author's intent more precisely than a pixel cluster. - A render is only spent when it could change the outcome. The "Add …" pre-check is the same precondition the detector applies, so a details panel with Save/Print buttons is never rendered.
- No add affordance, no hint. A print-only grid stays a plain set of fields.
- Chromium is required. Without it this path is skipped silently and detection is markup-only, exactly as before.
Verified on a CSS-grid rebuild of the VIP chart with no table markup at all: it
produces a matrix hint byte-identical to the one the markup detector returns for
the real <table> version — same labelHeader, same 22 rowLabels, same
instanceHeaders, same + Add Cannula / + Day controls.
The structure must be visible at load: a matrix behind a collapsed panel is still missed, and group boundaries inside one long parameter column are still inferred rather than measured.
Size and complexity limits
One conversion pass has to emit the whole Data + UI + Print schema set, so the binding constraint is the model's output budget, not the input file. Limits are therefore enforced up front, and an oversized mock-up is rejected with guidance rather than converted into a form that looks complete but silently lost its later sections:
| Limit | Default | Env var | On breach |
|---|---|---|---|
| File size | 2 MB (vs 10 MB for PDF/images) | — | 400 with the actual size |
| Fields (inputs/selects/textareas) | 160 | CONVERSION_MAX_FIELDS |
400 — "split into one file per section" |
| Table rows | 160 | CONVERSION_MAX_TABLE_ROWS |
400 — "split the large tables" |
| No fields found | — | — | 400 — the file is not a form mock-up (or everything was hidden) |
| Source chars (cleaned markup / extracted PDF text) | 32 000 | CONVERSION_MAX_SOURCE_CHARS |
truncated + POTENTIAL_MISSING_FIELD warning |
| Output-token budget per conversion call | 40 960 | CONVERSION_MAX_TOKENS |
model stops mid-object → run rejected (see below) |
The field/row counts are a claim about what the model must emit, so a
repeating log is counted as the one recordTable it becomes: when a rendered
page has filled a log whose hint came from the static markup, every row after
the first is discounted (countRepeatedLogRows). The Sepsis monitoring sheet is
the case that forced this — its hourly chart calls addVitalsRow() three times
at load, and those 39 identical inputs are 13 column definitions in the output.
Counted raw, the sheet came to 132 fields and was rejected as too large; counted
as the schema it produces, 106. Only rows of a table matching a restored hint
are ever discounted, so an ordinary data table still counts in full.
The field/row limits, the source-char budget and the conversion call's output
budget move together — raising the field limit alone would just trade a
clear rejection for a silently truncated form (or a silently clipped input).
They live in apps/api/src/modules/form-conversion/conversion-limits.ts and
are read from the environment, so an operator whose providers can emit more
raises them together at deployment level; on OpenAI reasoning models the
conversion also requests reasoning: { effort: 'low' } so thinking tokens do
not eat the schema's share of the output budget. As a backstop, if a model
still runs out of budget mid-object the run is rejected with "the AI ran out
of space … split it into one file per section" rather than the generic "not
valid JSON", which would send the author looking for a problem in their source
file.
These thresholds are calibrated against the output budget rather than measured per model, so they are the dial to turn if legitimate mock-ups start being rejected — but only up to what every configured provider can actually emit: Kimi/Minimax/Ollama ceilings are far below OpenAI GPT-5-family or Claude.
Multi-document files are flagged with a warning.
Limitations
The full list — with severity, workarounds, and the tracked issue that closes each gap — lives in CONVERSION-LIMITATIONS.md. Highlights:
- Scanned PDFs with only images require page-image rendering plus a vision-capable provider. Text-only providers need embedded text or future OCR support.
- Complex multi-page forms may exceed token limits for some providers.
- Generated schemas should always be reviewed before publishing — AI output is a starting point, not a final form.
- The jsonforms conversion's structural quality depends on the LLM; confidence/warnings + the review loop are the mitigation, not a guarantee.
- HTML mock-ups must be a single page: one form per file. Anything past the field/row limits above is rejected rather than partially converted.
- Hidden HTML is not converted, by design — with two narrow exceptions, both kept and given a SHOW rule: a conditional "Please specify…" field beside an "Other" option, and a section the mock-up's own script reveals (progressive disclosure, e.g. CAM-ICU's Features 2-4). If a mock-up hides anything else, make it visible before uploading; the conversion warning will say what was removed.
- Sections a mock-up builds with JavaScript are empty in the markup and cannot be recovered from the markup alone (a sandboxed render recovers the fields, and an opt-in parse recovers option lists — see Reading config from scripts). They are named in a conversion warning and left as a labelled gap rather than guessed at — see Sections built by JavaScript. A repeating log is the exception: its
<thead>and "Add …" button make it recoverable — see Repeating logs. A page that is only PARTLY script-built is rendered too — a container the page's own script fills is recovered even when the rest of the markup is full of fields — see A full form that still hides a checklist. If the whole form is script-built there is nothing to read at all and the upload is rejected with instructions — see When the whole form is built by JavaScript.