Eight findings from PR review of feat/acroform-field-import, all verified
against actual source in plan mode before fixing.
P1.1 — getTextFieldFormatHint never produced a hint in practice. PdfDict.get()
returns PdfRef for indirect entries (Adobe almost always emits /AA and /F as
indirect), and String(js) returned "[object Object]" because PdfString and
PdfStream don't override toString. Now we thread a RefResolver via
PDF.context.resolve through every dict lookup, use PdfDict.getDict(key,
resolver) so refs are auto-deref'd, and decode JS bodies via asString() (for
strings) or TextDecoder + getDecodedData() (for streams). The MaxLen probe had
the same bug — also fixed via getNumber(key, resolver). Branch ordering is
restructured so format actions take precedence over every name token, not
just within their own type bucket.
P1.2 — Signed-signature path had no test. Added a stub-based mock test that
drives extractAcroFormFieldsFromPDF against a SignatureField whose isSigned()
returns true and asserts hasSignedSignature: true, fields: [], and an
unsupported entry with reason: 'signed-signature'. Mirrored with a negative
control where isSigned() returns false.
P2.1 — hasXfa reached into PDFForm._acroForm (private readonly) via a cast.
Replaced with public catalog access: pdf.context.catalog.getDict() →
getDict('AcroForm', resolver) → has('XFA'). Removed the
fields.length === 0 short-circuit so pure-XFA docs with no /Fields surface
as xfa-hybrid instead of falling through.
P2.2 — When no signable recipient resolved, the AcroForm branch threw
AppError(NOT_FOUND) inside prisma.$transaction and tore down the entire
envelope creation. Replaced with logger.warn + early skip from the AcroForm
branch only. Matches UNSAFE_createEnvelopeItems' silent-skip behaviour.
P2.6 — Coverage gaps closed with 9 new test cases: encrypted (mock), xfa-
hybrid (mock), signed signature (mock + negative control), listbox unsupported
(mock), no-page-match (mock), format-action precedence (extends fixture),
/TU label fallback (extends fixture), required CHECKBOX (extends fixture
with Ff bit 2), hidden + off-page widgets (extends fixture).
P2.3 / P2.4 / P2.5 — Plan doc updated to match shipped implementation: rot=180
y formula corrected from `top` to `bottom`, heuristic regexes documented as
the lenient substring patterns actually shipped (with explicit false-positive
acknowledgements), signed-signature detection mechanism updated from raw /V
probe to SignatureField.isSigned().
Verification: 26/26 unit tests pass (was 17, +9). lib + trpc + remix
typecheck clean (lib retains 5 pre-existing unrelated errors). biome clean.
30 KiB
date, title
| date | title |
|---|---|
| 2026-05-21 | Acroform Field Detection And Reuse |
Problem
Users routinely prepare PDFs in Adobe Acrobat (or other PDF editors) with AcroForm fields — signatures, text inputs, dates, checkboxes — and upload them to Documenso. Today those widgets are either stripped (DOCUMENT upload flattens via form.flatten() in normalize-pdf.ts) or preserved as static interactive controls (TEMPLATE upload), but never reused as Documenso fields. Users have to re-place every field in the editor.
Issue: https://github.com/documenso/documenso/issues/2697 (labels: type: enhancement, apps: web).
Goal
On upload, detect AcroForm fields, map supported types to Documenso fields, persist their page geometry, then flatten the PDF so no duplicate interactive controls remain. Imported fields should be ordinary Field rows — visible in the editor, assignable to recipients, signable like any other field.
Background
The placeholder pipeline ({{signature, r1}} style) already does almost everything we need:
packages/lib/server-only/pdf/auto-place-fields.tsextractsPlaceholderInfo[](withfieldAndMeta: TFieldAndMeta, top-left percentages, page index), thenconvertPlaceholdersToFieldInputs(placeholders, recipientResolver, envelopeItemId)returnstx.field.createManypayloads.packages/trpc/server/envelope-router/create-envelope.tsper file:convertToPdf→ optionalinsertFormValuesInPdf→normalizePdf({ flattenForm: type !== 'TEMPLATE' })→extractPdfPlaceholders(normalized)→putPdfFileServerSide(cleanedPdf)→ forwards{ title, documentDataId, placeholders }.packages/lib/server-only/envelope-item/create-envelope-items.tsruns the same per-file pipeline when files are appended to an existing envelope.packages/lib/server-only/envelope/create-envelope.tsconsumesenvelopeItems[].placeholders, creates placeholderSIGNERrecipients (recipient.${i}@documenso.com) whendata.recipientsis empty, then callsconvertPlaceholdersToFieldInputs+tx.field.createManyinside its existing transaction.
@libpdf/core exposes the AcroForm primitives we need (verified in node_modules/@libpdf/core/dist/index.d.mts):
PDF.isEncrypted: boolean,PDF.getForm(): PDFForm | null,PDF.getPages()[i].ref+.getRotation().PDFForm.getFields(): FormField[].FormField:name,partialName,alternateName(/TU),isRequired(),isReadOnly(),acroField(): PdfDict(raw dict access),type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'listbox' | 'signature' | 'button' | 'unknown' | 'non-terminal'.WidgetAnnotation:rect,width,height,pageRef: PdfRef | null,isHidden(),isPrintable(),getOnValue().- Typed subclasses:
CheckboxField.isChecked()/getOnValues()/getOnValue(),DropdownField.getOptions(),RadioField.getOptions(),SignatureField,TextField.getText().
ZBaseFieldMeta (in packages/lib/types/field-meta.ts) already carries label, required, readOnly. We extend it once with source?: 'acroform' so imported fields are introspectable without UI changes.
Scope
In scope: server-side extraction at upload time for v2 envelopes (both new envelopes and items appended to existing envelopes), per-field FIELD_CREATED audit log entries for imported fields, and the one-line schema extension to record provenance. Out of scope: editor UI changes (badges, banners, review modals), signing surface changes, v1 path, listbox, button/unknown/non-terminal field types, true radio-group consolidation, recipient inference beyond first-signer selection.
Field Mapping
Type resolution
| AcroForm type | Documenso field | Rule |
|---|---|---|
signature (unsigned) |
SIGNATURE |
Import. |
signature (signed — SignatureField.isSigned() returns true) |
— | Skip. Log logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle }). Also downgrade flattenForm to false for that envelope item (do not re-flatten a signed PDF). |
text |
resolved by heuristic below | Heuristic order: AcroForm format action → name token → default to TEXT. |
checkbox |
CHECKBOX |
One Documenso field per widget. |
radio |
RADIO |
One Documenso field per widget. Store group's getOptions() in each fieldMeta.values. Semantics intentionally differ from PDF (each is independent); documented under Risks. |
dropdown |
DROPDOWN |
Preserve getOptions() in fieldMeta.values, current selection in fieldMeta.defaultValue. |
listbox, button, unknown, non-terminal |
— | Skip, return as AcroFormUnsupportedFieldInfo. Never block upload. |
Text-field heuristic (resolved in order — first match wins)
AcroForm format actions take precedence over every name token. If /AA → /F → /JS references a known formatter, that result is final. Only when no format action is detected do the name regexes apply.
- DATE if
acroField()carries an additional-actions date format (/AA→/F→JScontainingAFDate_FormatExorAFDate_Format). - NUMBER if
acroField()carries anAFNumber_Formataction. - DATE if name/alternateName matches
/date|dob|birth/i. - NUMBER if name/alternateName matches
/amount|qty|count|number/iAND/MaxLen <= 10. - EMAIL if name/alternateName matches
/email|e[-_]?mail/i. - NAME if name/alternateName matches
/name/i. - INITIALS if name/alternateName matches
/initial/i. - Else TEXT.
All regexes are case-insensitive and run against partialName then alternateName.
Patterns are intentionally lenient to handle CamelCase Adobe Acrobat names (e.g. CustomerName, BirthDate) that strict word-boundary patterns miss. Expected false positives — username → NAME, birth_name → DATE, initialize → INITIALS — are tolerable because the editor is the final arbiter; false negatives fall through to TEXT (always safe).
Metadata mapping
For every imported field:
fieldMeta.required = field.isRequired()(boolean; omit when false).fieldMeta.readOnly = field.isReadOnly()(boolean; omit when false). Read-only fields are imported (rendered for reference in the editor); the renderer already honoursreadOnly.fieldMeta.label:alternateName ?? partialNamefor label-supporting types (TEXT, NUMBER, DATE, INITIALS, NAME, EMAIL, DROPDOWN, RADIO, CHECKBOX). SIGNATURE has no label slot — drop it.fieldMeta.source = 'acroform'on every imported field (see Schema Extension).
CHECKBOX required semantics
AcroForm "required" on a checkbox means "must be checked". Documenso CHECKBOX has both required (field must be present) and validationRule/validationLength (e.g. "at least N of M"). Mapping:
if (field.isRequired()) {
fieldMeta.required = true;
fieldMeta.validationRule = 'at-least';
fieldMeta.validationLength = 1;
}
This approximates "must be checked to submit" for a single-widget checkbox field.
Default values (only when formValues was NOT provided on this upload)
formValues (via insertFormValuesInPdf) is authoritative when present — it bakes values into the flattened background, so the imported fields stay empty for the signer to fill. When formValues is absent, we copy PDF defaults into fieldMeta so the editor preview matches the source PDF:
| Source | Target |
|---|---|
TextField.getText() (non-empty) |
fieldMeta.text (TEXT/DATE/INITIALS/NAME/EMAIL) or fieldMeta.value (NUMBER) |
DropdownField current selection |
fieldMeta.defaultValue |
CheckboxField.isChecked() |
fieldMeta.values[0].checked = true |
RadioField current selection |
fieldMeta.values[i].checked = true on the matching option |
Always emit inserted: false and customText: '' — the signer still confirms each field, defaults are editor-only hints.
All metadata flows through ZEnvelopeFieldAndMetaSchema.parse(...) so imported fields match what the editor already expects.
Pre-extraction Guards
Run in this order before any AcroForm work:
- Encrypted PDFs: if
pdfDoc.isEncrypted→ log{ event: 'acroform-import.skip', reason: 'encrypted', envelopeItemTitle }, return{ fields: [], unsupported: [] }. Upload proceeds with zero imported fields. - XFA hybrid: detect via the catalog's
AcroFormdict carrying anXFAkey (best-effort via raw dict access; if @libpdf/core's public surface can't read it, fall through — mirrored AcroForm fields in XFA hybrids are fine to import). When detected, log{ event: 'acroform-import.skip', reason: 'xfa-hybrid' }and return empty results. getForm()is null → return empty results silently.- Top-level try/catch around steps 1–6: any throw →
logger.error({ event: 'acroform-import.error', envelopeItemTitle, err }), return{ fields: [], unsupported: [] }. Upload proceeds untouched. Never bubble.
Coordinate Handling
Reuse the placeholder convention (top-left percentages, see auto-place-fields.ts:121-138):
- Build a page lookup once:
pages = pdfDoc.getPages(); pageByRef = new Map(pages.map((p, i) => [p.ref, i])). - For each widget:
- Read
widget.rect = [x1, y1, x2, y2](bottom-left, points). - Normalize:
left = min(x1, x2),right = max,bottom = min(y1, y2),top = max. - Resolve
pageIndexviapageByRef.get(widget.pageRef). Skip if no match. - Read
page.width,page.height,rot = page.getRotation()(degrees, normalized to0|90|180|270). - Apply inverse rotation transform so the field lands at the rendered top-left percentage:
rot === 0:x = left,y = pageH - top,w = right - left,h = top - bottom. Page dims(pageW, pageH).rot === 90:x = bottom,y = left,w = top - bottom,h = right - left. Page dims swap:(pageH, pageW).rot === 180:x = pageW - right,y = bottom,w = right - left,h = top - bottom. Page dims(pageW, pageH).rot === 270:x = pageH - top,y = pageW - right,w = top - bottom,h = right - left. Page dims swap.
- Out-of-bounds policy: if the entire rect is outside the rotated page bounds, skip + emit
AcroFormUnsupportedFieldInfowithreason: 'off-page'. Otherwise clamp to[0, renderedW] × [0, renderedH]. - Convert to percentages against the rendered page dimensions from step 5.
- Apply the existing
MIN_HEIGHT_THRESHOLD/DEFAULT_FIELD_HEIGHT_PERCENTfallback used by placeholders.
- Read
- Skip widgets that are
isHidden()or have zero/negativewidth/heightafter normalization.
Ordering
Sort imported fields before createMany by (pageIndex asc, top-to-bottom, left-to-right). Concretely: ascending pageIndex, then ascending y (top-of-page first), then ascending x within ±2% y-buckets so a row of fields stays a row. This matches how a signer visually scans the page; it does not rely on AcroForm /Tabs metadata (often wrong).
Audit Logging
Every imported field emits one FIELD_CREATED entry matching create-envelope-fields.ts:264's shape:
await tx.documentAuditLog.createMany({
data: createdFields.map((f) => createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: { fieldId: f.secondaryId, fieldRecipientEmail, fieldRecipientId, fieldType: f.type },
})),
});
The placeholder branch in create-envelope.ts is silent today and stays silent — AcroForm import does not retroactively change that. Distinguishing imported vs. placeholder vs. user-placed fields is done via fieldMeta.source, not a new audit type.
Schema Extension
One field added to ZBaseFieldMeta in packages/lib/types/field-meta.ts:
export const ZBaseFieldMeta = z.object({
// existing...
source: z.enum(['acroform']).optional(),
});
No DB migration (fieldMeta is JSON). No editor change. No API contract change beyond the optional field. Forwards-compatible: future sources ('placeholder', 'figma', etc.) extend the enum.
Plan
1. Add the AcroForm extractor
New file packages/lib/server-only/pdf/acroform-fields.ts:
export type AcroFormFieldImportInfo = {
source: 'acroform';
fieldName: string;
widgetIndex: number;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export type AcroFormUnsupportedFieldInfo = {
fieldName: string;
acroFormType: string;
reason: 'unsupported-type' | 'hidden' | 'off-page' | 'zero-size' | 'no-page-match' | 'signed-signature';
};
export type AcroFormExtractionResult = {
fields: AcroFormFieldImportInfo[];
unsupported: AcroFormUnsupportedFieldInfo[];
/** True when a signed signature widget was found — caller MUST set flattenForm: false for that item. */
hasSignedSignature: boolean;
/** True when extraction returned empty for a reason that should be surfaced in logs but not propagated. */
skipReason?: 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
};
export const extractAcroFormFieldsFromPDF = async (
pdf: Buffer,
): Promise<AcroFormExtractionResult>;
export const convertAcroFormFieldsToFieldInputs = (
fields: AcroFormFieldImportInfo[],
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[];
extractAcroFormFieldsFromPDF:
- Wraps everything in try/catch (top-level guard).
- Loads via
PDF.load(new Uint8Array(pdf)). - Runs pre-extraction guards (encrypted, XFA, null form) — returns early with
skipReasonset. - Builds the page-ref → index + rotation lookup once.
- Iterates
form.getFields(), applies the type-resolution heuristic, geometry pipeline, default-value mapping. - Records signed-signature widgets in
unsupportedwithreason: 'signed-signature'AND setshasSignedSignature = true. - Logger is module-scoped (no apiRequestMetadata in this file — pure function).
convertAcroFormFieldsToFieldInputs mirrors convertPlaceholdersToFieldInputs — pure point→percentage transform, no DB access. After mapping, sort by (page, y, x) as in Ordering.
Kept separate from auto-place-fields.ts: placeholders are text-driven and emit white rectangles via whiteoutRegions; AcroForm import is widget-driven and relies on the post-extraction form.flatten() to clean the PDF. Sharing types prematurely would couple both paths.
2. Extract AcroForm fields before flattening
In both upload entry points the order becomes:
convertToPdf (router only)
→ insertFormValuesInPdf if formValues
→ extractAcroFormFieldsFromPDF(pdf) // new — must run BEFORE normalizePdf
→ const shouldFlatten = type !== 'TEMPLATE' && !extraction.hasSignedSignature
→ normalizePdf({ flattenForm: shouldFlatten })
→ extractPdfPlaceholders(normalized)
→ putPdfFileServerSide(cleanedPdf)
→ forward { placeholders, acroFormFields, formValuesProvided }
Why before normalizePdf: for DOCUMENT uploads normalizePdf calls form.flatten() and destroys widget geometry. Extraction must read the unflattened buffer. formValues filling stays first so user-prefilled values still bake into the flattened background.
When extraction.hasSignedSignature is true, also logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle }).
formValuesProvided (boolean) is forwarded to the converter so the default-value mapping can skip prefill when the user-supplied values pipeline already filled the PDF.
Template flattening policy is unchanged in this plan: templates continue to preserve AcroForm widgets (no form.flatten()), so imported fields will visually duplicate the still-interactive PDF widgets in the template preview. Flipping templates to flatten is a follow-up — it's a breaking change for API users relying on template formValues.
3. Thread acroFormFields through createEnvelope
Extend CreateEnvelopeOptions.data.envelopeItems[number] (packages/lib/server-only/envelope/create-envelope.ts:70-75):
envelopeItems: {
title?: string;
documentDataId: string;
order?: number;
placeholders?: PlaceholderInfo[];
acroFormFields?: AcroFormFieldImportInfo[]; // new
formValuesProvided?: boolean; // new — already-applied prefill
}[];
Inside the existing transaction (alongside the itemsWithPlaceholders branch at :431-538), add an itemsWithAcroFormFields branch:
- Run AFTER the placeholder branch so
availableRecipientsreflects any placeholder signers it created. - Recipient resolution:
- First-signer rule: pick
availableRecipients.filter(r => r.role === SIGNER || r.role === APPROVER).sort((a, b) => (a.signingOrder ?? Infinity) - (b.signingOrder ?? Infinity) || a.id - b.id)[0]. - If none: the placeholder branch may have created
Recipient 1already — reuse. If still none (no recipients, no placeholders), create one placeholderSIGNERvia the samerecipient.1@documenso.comshape used by the placeholder branch. - All imported fields → that one recipient. User reassigns in editor.
- First-signer rule: pick
- Call
convertAcroFormFieldsToFieldInputs(item.acroFormFields, resolver, envelopeItem.id)thentx.field.createMany(...)with the same{ envelopeId, envelopeItemId, recipientId, type, page, positionX, positionY, width, height, customText: '', inserted: false, fieldMeta }shape used by the placeholder branch. - Immediately after, emit per-field
FIELD_CREATEDaudit log entries (see Audit Logging).
4. Mirror in UNSAFE_createEnvelopeItems
packages/lib/server-only/envelope-item/create-envelope-items.ts:47-77 — carry acroFormFields and formValuesProvided alongside placeholders in envelopeItemsToCreate. Inside the existing if (envelope.recipients.length > 0) block (:111-160), after the placeholder loop, run the AcroForm loop using the same first-signer rule (SIGNER|APPROVER, signingOrder asc, id asc). Emit per-field FIELD_CREATED entries with apiRequestMetadata. If envelope.recipients.length === 0, skip — appending widgets to a recipient-less envelope is the user's setup phase and is handled when they add recipients (matches current placeholder behavior on append).
5. Log unsupported fields, never block upload
In both entry points, after extraction:
if (extraction.unsupported.length > 0) {
logger.info({
event: 'acroform-import.unsupported',
envelopeItemTitle,
count: extraction.unsupported.length,
byReason: groupBy(extraction.unsupported, u => u.reason),
});
}
if (extraction.skipReason) {
logger.info({ event: 'acroform-import.skip', envelopeItemTitle, reason: extraction.skipReason });
}
No new error type, no upload rejection, no response-shape change. A UI surface for warnings comes later once the upload response has a stable warning shape.
6. Schema extension
packages/lib/types/field-meta.ts: add source: z.enum(['acroform']).optional() to ZBaseFieldMeta. Single-line change, no callers need updating because the field is optional.
Files
| File | Change |
|---|---|
packages/lib/types/field-meta.ts |
Add source?: 'acroform' to ZBaseFieldMeta. |
packages/lib/server-only/pdf/acroform-fields.ts |
new — extractor, converter, types, pre-extraction guards, heuristics, geometry pipeline, ordering. |
packages/lib/server-only/envelope/create-envelope.ts |
Extend CreateEnvelopeOptions.envelopeItems[] with acroFormFields + formValuesProvided. Add AcroForm branch beside placeholder branch (~:431-538). Emit per-field FIELD_CREATED audit entries. |
packages/trpc/server/envelope-router/create-envelope.ts |
Insert extractAcroFormFieldsFromPDF before normalizePdf in the per-file loop (:110-141). Downgrade flattenForm when hasSignedSignature. Forward acroFormFields + formValuesProvided into the envelopeItems payload (:135-140). Log unsupported + skipReason. |
packages/lib/server-only/envelope-item/create-envelope-items.ts |
Insert extractAcroFormFieldsFromPDF before normalizePdf (:48-77). Same flatten downgrade. Carry acroFormFields + formValuesProvided in envelopeItemsToCreate. Add AcroForm loop inside envelope.recipients.length > 0 (:111-160). Per-field audit entries. Log unsupported + skipReason. |
packages/lib/server-only/pdf/acroform-fields.test.ts |
new — unit suite (see Tests). |
packages/app-tests/e2e/scenarios/acroform-import.spec.ts |
new — e2e suite (see Tests). |
scripts/generate-acroform-test-pdf.mjs |
new — one-off generator (committed) producing assets/acroform-import-test.pdf + rotated variants. |
assets/acroform-import-test.pdf |
new — base fixture: one of each supported type. |
assets/acroform-import-rotated-90.pdf |
new — rotated-page fixture. |
assets/acroform-import-rotated-180.pdf |
new — rotated-page fixture. |
assets/acroform-import-rotated-270.pdf |
new — rotated-page fixture. |
assets/acroform-import-signed.pdf |
new — fixture with one signed signature widget + supported widgets. |
No DB schema change. No new tRPC route. No public API surface change beyond the optional fieldMeta.source.
Tests
Unit (packages/lib/server-only/pdf/acroform-fields.test.ts)
Drive from the committed fixture set; synthesize edge-case PDFs inline via @libpdf/core's form builder where a static file is overkill.
Type resolution:
- text / signature / checkbox / radio / dropdown widgets each produce the expected Documenso field type.
- Heuristic positives:
signed_date/dob→ DATE;initial/initials→ INITIALS;customer_email→ EMAIL;full_name/fname→ NAME; field withAFNumber_Formataction → NUMBER; field withMaxLen: 5+ nameqty→ NUMBER; plaincustomer_id→ TEXT. - AcroForm format actions take precedence over name tokens (a field named
customer_namewith anAFDate_FormatExaction → DATE).
Metadata:
isRequired/isReadOnlyround-trip intofieldMeta.alternateName(orpartialNamefallback) →fieldMeta.labelon label-supporting types; SIGNATURE has no label.- Required CHECKBOX →
required: true+validationRule: 'at-least'+validationLength: 1. - Every imported field has
fieldMeta.source = 'acroform'.
Default values:
- TextField with non-empty value AND
formValuesProvided = false→fieldMeta.textset. - TextField with non-empty value AND
formValuesProvided = true→fieldMeta.textNOT set. - DropdownField selection →
fieldMeta.defaultValue. - CheckboxField checked →
values[0].checked = true. - RadioField selected → matching
values[i].checked = true.
Geometry:
- Bottom-left widget rect
[100, 600, 200, 620]on a 612×792 page → top-left percentages within ±0.01% of expected. - 90° rotated page: same widget rect → rotated coordinates as defined in Coordinate Handling step 5.
- 180° and 270° rotated pages: same.
- Hidden widgets (annotation flags hidden bit) → skipped.
- Widgets with zero/negative dimensions → skipped.
- Widgets with
pageRefnot inpdfDoc.getPages()→unsupportedwithreason: 'no-page-match'. - Widget rect entirely off-page →
unsupportedwithreason: 'off-page'. - Widget rect partially off-page → clamped, imported.
Ordering:
- Two pages × four widgets in scrambled creation order → output sorted by
(page, y, x).
Skips and unsupported:
- listbox / button / unknown / non-terminal →
unsupported, never thrown. - Encrypted PDF →
skipReason: 'encrypted',fields: [], no throw. - XFA hybrid PDF (best-effort detect) →
skipReason: 'xfa-hybrid'when detectable; otherwise extraction proceeds normally. - Signed signature widget (
SignatureField.isSigned()returns true) →unsupportedwithreason: 'signed-signature'ANDhasSignedSignature: true. - Buffer corruption → top-level try/catch, returns empty +
skipReason: 'error', no throw.
E2E (packages/app-tests/e2e/scenarios/acroform-import.spec.ts)
- Upload
assets/acroform-import-test.pdfas aDOCUMENTvia the v2 envelope router with one provided SIGNER recipient → assert envelope has oneFieldper supported widget, types match,positionX/Y/width/heightwithin ±1% of expected, every field's recipient is that one SIGNER, stored PDF (documentData) loaded viaPDF.loadreportsgetForm() === nullorgetFields().length === 0. Audit log contains NFIELD_CREATEDentries. - Upload with
formValuespopulated →formValuespersists, imported fields exist but have no default values set in fieldMeta, the flattened PDF reflects the prefilled values. - Upload with two recipients: one CC + one SIGNER → all imported fields assigned to the SIGNER (CC skipped).
- Upload with two recipients: one SIGNER (signingOrder=1) + one APPROVER (signingOrder=2) → all imported fields assigned to the SIGNER.
- Upload with zero recipients → placeholder
Recipient 1created (shared with the placeholder branch's behavior; if both placeholders and AcroForm fields exist in the same file, only oneRecipient 1exists). - Upload
assets/acroform-import-signed.pdf→ signed signature widget skipped, other widgets imported, stored PDF is NOT flattened (getForm() !== null, widgets still present). - Append
assets/acroform-import-test.pdfto an existing envelope with one SIGNER viaUNSAFE_createEnvelopeItems→ newenvelopeItem.idcarries the imported fields, all assigned to that SIGNER. - Append to a recipient-less envelope → AcroForm extraction runs, fields are NOT created (skipped, matching placeholder behavior).
- Upload
TEMPLATE→ template still preserves AcroForm widgets in the stored PDF (current behavior unchanged), imported fields ALSO exist (visual duplication acknowledged in Risks). - Upload PDF with one
listbox+ one supportedtextfield → upload succeeds, only the text field becomes a Documenso field, log line emitted for the listbox. - Upload rotated PDFs (90/180/270 fixtures) → field geometry lands within ±1% of the expected rendered position on each page.
Regression
npx tsc --noEmit -p apps/remix/tsconfig.json
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/form-flattening.spec.ts
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/acroform-import.spec.ts
Behavior Matrix
| Upload | Has AcroForm | Signed sig? | formValues? |
recipients? |
Result |
|---|---|---|---|---|---|
DOCUMENT |
yes | no | none | 1 SIGNER/APPROVER | All imported fields → that recipient. Stored PDF flat. Per-field FIELD_CREATED audit. |
DOCUMENT |
yes | no | none | N≥2 mixed roles | All imported fields → first SIGNER |
DOCUMENT |
yes | no | none | only CC/VIEWER | Treated as "no signable recipients" — placeholder Recipient 1 SIGNER created. |
DOCUMENT |
yes | no | none | none | One placeholder Recipient 1 SIGNER created (reused if placeholder branch already made one). |
DOCUMENT |
yes | no | provided | any | formValues filled → flattened values visible → empty supported fields imported with source: 'acroform', no fieldMeta.text/defaultValue prefill. |
DOCUMENT |
yes | yes | any | any | Signed signature(s) skipped + logged. flattenForm downgraded to false → stored PDF retains widgets. Other supported widgets imported normally. |
DOCUMENT |
no | n/a | any | any | Unchanged. |
TEMPLATE |
yes | n/a | any | any | Imported fields created and PDF still contains interactive widgets (known artifact, follow-up). |
| Encrypted PDF | n/a | n/a | any | any | Extraction skipped + logged. Upload proceeds with zero AcroForm imports. |
| XFA hybrid (detected) | n/a | n/a | any | any | Extraction skipped + logged. Same as encrypted. |
| Append to existing envelope w/ recipients | yes | no | n/a | n/a | Imported fields → first SIGNER |
| Append to existing envelope w/o recipients | yes | n/a | n/a | n/a | Skipped (matches current placeholder behavior on append). |
Out of Scope / Follow-ups
- Flipping
TEMPLATEuploads to flatten after import — breaking for API users relying on template AcroFormformValues. - Editor UI surface: "Imported from PDF" badge (using
fieldMeta.source), warning toast for skipped widgets, encrypted/XFA banner. Data is captured now; UI ships separately. - A signed-AcroForm-signature → completed Documenso signature mapping.
- True radio-group consolidation (one Documenso field per AcroForm radio group instead of per widget) — needs
fieldMetaschema extension for multi-position groups. - Same-name multi-widget non-radio fields (one AcroForm text field rendered on N pages) — currently emit N independent Documenso fields; future work could sync values at signing time via a shared
groupIdin fieldMeta. - Listbox support.
- Recipient inference from PDF authoring metadata (Adobe's role/recipient hints, tab order grouping).
- AcroForm
/Tabsordering as a signal — current spatial sort suffices.
Risks
- Rotated pages: covered by inverse-rotation transform with 90/180/270 fixtures gating the unit suite. Skewed rotations (non-cardinal) are not supported; should be rejected as
off-pageif their normalized rect doesn't land within page bounds. - Radio groups: emitting one Documenso field per widget will look right visually but signing semantics differ from a single PDF radio group (each option becomes independently checkable). Gating fixture in e2e covers visual placement; signing semantics divergence is documented and ships as a known limitation.
- Template behavior: leaving
TEMPLATEuploads unflattened means imported fields and live widgets coexist. Acceptable for v1, but the template preview will show duplicated controls. - Signed signature + flattenForm downgrade: a
DOCUMENTupload containing a signed signature now stores an un-flattened PDF. Existing code paths that assumeDOCUMENTPDFs are always flat (signing renderer, downstream conversion) MUST be re-verified — add an integration check in the e2e suite that signing still works on the signed-fixture envelope. - Recipient ambiguity: AcroForm widgets don't encode Documenso recipients. Deterministic "all to first signer" + editor review is the safest first cut; smarter assignment is a follow-up.
- XFA detection: best-effort; if @libpdf/core's public surface doesn't expose the catalog AcroForm dict, we fall through and import any mirrored AcroForm fields. Acceptable — XFA-only PDFs with no mirror produce empty AcroForm extraction and the upload proceeds. Worst case is a noisy log line on a misclassified hybrid.
- Heuristic false positives: expanded heuristic (NAME/EMAIL/NUMBER/DATE/INITIALS) increases the chance of mis-typing a field. Mitigation: every imported field is editable in the editor before sending. False negatives fall through to TEXT (always safe).