Form-Aware Conversion
The problem
The primary product (TheAccessiblePDF, apps/web) outputs a remediated PDF
generated from HTML via WeasyPrint. WeasyPrint renders static output β it cannot
emit interactive AcroForm fields. So when the source PDF is a fillable form, the
remediated PDF is screen-reader readable but not fillable.
A working accessible form requires HTML (native <input>/<select>, real
<label>s, keyboard + AT support). That is why form-routing was disabled in the
main converter β not because forms are infeasible, but because the PDF output
canβt carry them.
The model (decided 2026-07-25)
A PDF is a mixed document β some pages are a form, most usually arenβt. So we do not route the whole file to a separate processor. Instead:
- Convert the whole PDF normally (all pages) β accessible HTML + remediated PDF.
- When a form is detected, also produce a fillable HTML form variant via the form processor.
- Tell the user at delivery which output is fillable: the HTML form works; the PDF form is static. Never block β they keep both.
Detection is free and already always-on: pdf-preflight.ts calls
getFieldObjects() and sets hasFormFields / formFieldCount on every convert.
What already exists (all gated off before this change)
The full pipeline was already built and hard-disabled behind four false flags:
| Layer | Gate | Location |
|---|---|---|
| struct-table form variant | FORM_VARIANT_ENABLED | convert.ts |
| main-path form routing | FORM_ROUTING_ENABLED | convert.ts |
| chunk-assembler form routing | FORM_ROUTING_ENABLED | chunk-assembler.ts |
| frontend form UI | FORM_UI_ENABLED | apps/web/src/lib/feature-flags.ts |
Supporting pieces already present: prependRegularTextToSkeleton() (keeps non-form
prose when building the form variant), premiumFormR2Key storage, the
POST /api/convert/:fileId/premium-form endpoint, and the frontend download row +
banners in WizardStep4Download.tsx.
What this change does
Makes the feature controllable and correctly messaged, without turning it on in prod:
- Backend: the three hardcoded flags now read
process.env.FORM_VARIANT_ENABLED === 'true'(default off) β same env-flag pattern asTABLE_FIT_ENABLED/WCAG_LONG_DESCRIPTIONS. - Frontend:
FORM_UI_ENABLEDnow readsprocess.env.NEXT_PUBLIC_FORM_UI_ENABLED === 'true'(default off). - Preflight
form-fieldsadvisory rewritten from a vaguewarningto an actionableinfo: HTML can be fillable, PDF form is static. WizardStep4Downloadgains a completion notice (βyour fillable form is the Accessible Form download; the PDF form is staticβ) and marks the PDF row static.
Behavior in prod is unchanged (flags default off) until the env vars are set.
Rollout
- In one environment (staging or the Node server
.4), setFORM_VARIANT_ENABLED=trueand buildapps/webwithNEXT_PUBLIC_FORM_UI_ENABLED=true. - Run a real end-to-end form conversion; confirm the Accessible Form download is fillable and the completion notice renders. Confirm cost per page is acceptable (the form processor uses AI iterations).
- Enable in prod via env once validated (Node
.env.node-server, Lambda env, and theapps/webbuild env).
Decisions (2026-07-25)
- Pricing β INCLUDED at no additional cost. The fillable HTML form is produced automatically as part of the conversion the user already paid for. There is no separate per-page form charge and no on-demand paid βcreate formβ action. The free mechanism is the auto path below.
- Engine β the
forms.theaccessible.orgengine. The main-converter auto paths (convert.tsFORM_ROUTING block +chunk-assembler.ts) already callextractAcroFormFieldsβmapFieldsToHtml(form-field-mapper) βconvertHybridForm(form-hybrid-converter) β the same engine as the standalone/api/forms/*product. This is our single engine.premium-form-converter(convertPremiumForm) is the retired duplicate: it was the paid upsell engine behindPOST /api/convert/:fileId/premium-form. Its on-demand UI (upsell banner/menu item, β1 credit/pageβ, βPremiumβ badge) has been removed. The endpoint itself is now vestigial β remove it (and itsreserveUsage/deductCreditsbilling) in the convergence follow-up.- Minor inconsistency to clean up: the struct-table fast-path variant
(
convert.ts~1379) still builds the form from visionformHtml+runPostProcessingrather than the hybrid engine. Route it throughconvertHybridFormtoo for one code path.
Hijack fixed β both paths are now additive
The trigger is hasFormFields (true for any stray AcroForm field), so a path that
replaced the primary output with the form would hijack normal conversions. Both
paths now store the form only as an additive variant (premiumFormR2Key) and
leave the primary document output untouched:
convert.tsmain path β additive (removedhtml = formHtml).chunk-assembler.ts(chunked/larger PDFs) β additive: generates the form into a localformVariantHtml, stores it atR2_PATHS.formHtml, returnspremiumFormR2Key+formFieldCount, and the scheduler merges them into the file metadata.isFormRoutedstaysfalse, so the assembled document always gets normal post-processing. (#1769)
Still do not flip FORM_VARIANT_ENABLED in prod until an end-to-end run
confirms both a real form PDF (β fillable form variant + intact document) and a
normal PDF containing a stray field (β unchanged document, no hijack). A tighter
trigger than hasFormFields (fillable-field density) is still worth adding.
Open decisions (not resolved here)
- Option A vs B for the HTML deliverable.
- A (current): the fillable form is a separate βAccessible Formβ HTML artifact; the main HTML/PDF show the form static. Shipped-shape today.
- B (future): merge the interactive fields in place into the full-document HTML so the single HTML deliverable is the whole document with a working form. Better UX; more work (splice fields into the correct page regions).
Related
forms.theaccessible.org(apps/forms) β the standalone form product; keep as a dedicated front door, but converge its engine with this path.