Skip to content

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:

  1. Convert the whole PDF normally (all pages) β†’ accessible HTML + remediated PDF.
  2. When a form is detected, also produce a fillable HTML form variant via the form processor.
  3. 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:

LayerGateLocation
struct-table form variantFORM_VARIANT_ENABLEDconvert.ts
main-path form routingFORM_ROUTING_ENABLEDconvert.ts
chunk-assembler form routingFORM_ROUTING_ENABLEDchunk-assembler.ts
frontend form UIFORM_UI_ENABLEDapps/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 as TABLE_FIT_ENABLED / WCAG_LONG_DESCRIPTIONS.
  • Frontend: FORM_UI_ENABLED now reads process.env.NEXT_PUBLIC_FORM_UI_ENABLED === 'true' (default off).
  • Preflight form-fields advisory rewritten from a vague warning to an actionable info: HTML can be fillable, PDF form is static.
  • WizardStep4Download gains 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

  1. In one environment (staging or the Node server .4), set FORM_VARIANT_ENABLED=true and build apps/web with NEXT_PUBLIC_FORM_UI_ENABLED=true.
  2. 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).
  3. Enable in prod via env once validated (Node .env.node-server, Lambda env, and the apps/web build 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.org engine. The main-converter auto paths (convert.ts FORM_ROUTING block + chunk-assembler.ts) already call extractAcroFormFields β†’ 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 behind POST /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 its reserveUsage/deductCredits billing) in the convergence follow-up.
    • Minor inconsistency to clean up: the struct-table fast-path variant (convert.ts ~1379) still builds the form from vision formHtml + runPostProcessing rather than the hybrid engine. Route it through convertHybridForm too 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.ts main path β€” additive (removed html = formHtml).
  • chunk-assembler.ts (chunked/larger PDFs) β€” additive: generates the form into a local formVariantHtml, stores it at R2_PATHS.formHtml, returns premiumFormR2Key + formFieldCount, and the scheduler merges them into the file metadata. isFormRouted stays false, 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).
  • forms.theaccessible.org (apps/forms) β€” the standalone form product; keep as a dedicated front door, but converge its engine with this path.