diff --git a/.agents/plans/loud-orange-leaf-acroform-field-detection-and-reuse.md b/.agents/plans/loud-orange-leaf-acroform-field-detection-and-reuse.md new file mode 100644 index 000000000..e1ccb255d --- /dev/null +++ b/.agents/plans/loud-orange-leaf-acroform-field-detection-and-reuse.md @@ -0,0 +1,424 @@ +--- +date: 2026-05-21 +title: 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.ts` extracts `PlaceholderInfo[]` (with `fieldAndMeta: TFieldAndMeta`, top-left percentages, page index), then `convertPlaceholdersToFieldInputs(placeholders, recipientResolver, envelopeItemId)` returns `tx.field.createMany` payloads. +- `packages/trpc/server/envelope-router/create-envelope.ts` per file: `convertToPdf` → optional `insertFormValuesInPdf` → `normalizePdf({ flattenForm: type !== 'TEMPLATE' })` → `extractPdfPlaceholders(normalized)` → `putPdfFileServerSide(cleanedPdf)` → forwards `{ title, documentDataId, placeholders }`. +- `packages/lib/server-only/envelope-item/create-envelope-items.ts` runs the same per-file pipeline when files are appended to an existing envelope. +- `packages/lib/server-only/envelope/create-envelope.ts` consumes `envelopeItems[].placeholders`, creates placeholder `SIGNER` recipients (`recipient.${i}@documenso.com`) when `data.recipients` is empty, then calls `convertPlaceholdersToFieldInputs` + `tx.field.createMany` inside 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 — `acroField().has('V')` is true) | — | **Skip.** Log `logger.warn({ event: 'acroform-import.signed-signature', envelopeItemTitle, fieldName })`. 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) + +1. **DATE** if `acroField()` carries an additional-actions date format (`/AA` → `/F` → `S = JavaScript` referencing `AFDate_FormatEx`) **or** name/alternateName matches `/date|dob|birth_date|signed_date/i`. +2. **NUMBER** if `acroField()` carries an `AFNumber_Format` action **or** (`/MaxLen <= 10` AND name matches `/amount|qty|count|number|num\b/i`). +3. **EMAIL** if name/alternateName matches `/\bemail\b|e[-_]?mail/i`. +4. **NAME** if name/alternateName matches `/\bname\b|full_?name|first_?name|last_?name|fname\b|lname\b/i`. +5. **INITIALS** if name/alternateName matches `/initial(s)?\b|\binit\b/i`. +6. Else **TEXT**. + +All regexes are case-insensitive and run against `partialName` then `alternateName`. False positives are reviewable in the editor; 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 honours `readOnly`. +- `fieldMeta.label`: `alternateName ?? partialName` for 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: + +```ts +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: + +1. **Encrypted PDFs**: if `pdfDoc.isEncrypted` → log `{ event: 'acroform-import.skip', reason: 'encrypted', envelopeItemTitle }`, return `{ fields: [], unsupported: [] }`. Upload proceeds with zero imported fields. +2. **XFA hybrid**: detect via the catalog's `AcroForm` dict carrying an `XFA` key (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. +3. **`getForm()` is null** → return empty results silently. +4. **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`): + +1. Build a page lookup once: `pages = pdfDoc.getPages(); pageByRef = new Map(pages.map((p, i) => [p.ref, i]))`. +2. For each widget: + 1. Read `widget.rect = [x1, y1, x2, y2]` (bottom-left, points). + 2. Normalize: `left = min(x1, x2)`, `right = max`, `bottom = min(y1, y2)`, `top = max`. + 3. Resolve `pageIndex` via `pageByRef.get(widget.pageRef)`. Skip if no match. + 4. Read `page.width`, `page.height`, `rot = page.getRotation()` (degrees, normalized to `0|90|180|270`). + 5. 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 = top`, `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. + 6. Out-of-bounds policy: if the entire rect is outside the rotated page bounds, skip + emit `AcroFormUnsupportedFieldInfo` with `reason: 'off-page'`. Otherwise clamp to `[0, renderedW] × [0, renderedH]`. + 7. Convert to percentages against the rendered page dimensions from step 5. + 8. Apply the existing `MIN_HEIGHT_THRESHOLD` / `DEFAULT_FIELD_HEIGHT_PERCENT` fallback used by placeholders. +3. Skip widgets that are `isHidden()` or have zero/negative `width`/`height` after 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: + +```ts +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`: + +```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`: + +```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; + +export const convertAcroFormFieldsToFieldInputs = ( + fields: AcroFormFieldImportInfo[], + recipientResolver: (fieldName: string) => Pick, + 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 `skipReason` set. +- 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 `unsupported` with `reason: 'signed-signature'` AND sets `hasSignedSignature = 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`): + +```ts +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 `availableRecipients` reflects 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 1` already — reuse. If still none (no recipients, no placeholders), create one placeholder `SIGNER` via the same `recipient.1@documenso.com` shape used by the placeholder branch. + - All imported fields → that one recipient. User reassigns in editor. +- Call `convertAcroFormFieldsToFieldInputs(item.acroFormFields, resolver, envelopeItem.id)` then `tx.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_CREATED` audit 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: + +```ts +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 with `AFNumber_Format` action → NUMBER; field with `MaxLen: 5` + name `qty` → NUMBER; plain `customer_id` → TEXT. +- AcroForm format actions take precedence over name tokens (a field named `customer_name` with an `AFDate_FormatEx` action → DATE). + +Metadata: +- `isRequired` / `isReadOnly` round-trip into `fieldMeta`. +- `alternateName` (or `partialName` fallback) → `fieldMeta.label` on 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.text` set. +- TextField with non-empty value AND `formValuesProvided = true` → `fieldMeta.text` NOT 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 `pageRef` not in `pdfDoc.getPages()` → `unsupported` with `reason: 'no-page-match'`. +- Widget rect entirely off-page → `unsupported` with `reason: '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 (`/V` present) → `unsupported` with `reason: 'signed-signature'` AND `hasSignedSignature: 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.pdf` as a `DOCUMENT` via the v2 envelope router with one provided SIGNER recipient → assert envelope has one `Field` per supported widget, types match, `positionX/Y/width/height` within ±1% of expected, every field's recipient is that one SIGNER, stored PDF (`documentData`) loaded via `PDF.load` reports `getForm() === null` or `getFields().length === 0`. Audit log contains N `FIELD_CREATED` entries. +- Upload with `formValues` populated → `formValues` persists, 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 1` created (shared with the placeholder branch's behavior; if both placeholders and AcroForm fields exist in the same file, only one `Recipient 1` exists). +- 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.pdf` to an existing envelope with one SIGNER via `UNSAFE_createEnvelopeItems` → new `envelopeItem.id` carries 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 supported `text` field → 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 + +```bash +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|APPROVER by (signingOrder asc, id asc). CC/VIEWER skipped. User reassigns in editor. | +| `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|APPROVER of the envelope. Per-field audit. | +| 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 `TEMPLATE` uploads to flatten after import — breaking for API users relying on template AcroForm `formValues`. +- 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 `fieldMeta` schema 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 `groupId` in fieldMeta. +- Listbox support. +- Recipient inference from PDF authoring metadata (Adobe's role/recipient hints, tab order grouping). +- AcroForm `/Tabs` ordering 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-page` if 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 `TEMPLATE` uploads unflattened means imported fields and live widgets coexist. Acceptable for v1, but the template preview will show duplicated controls. +- **Signed signature + flattenForm downgrade**: a `DOCUMENT` upload containing a signed signature now stores an un-flattened PDF. Existing code paths that assume `DOCUMENT` PDFs 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). diff --git a/assets/acroform-import-rotated-180.pdf b/assets/acroform-import-rotated-180.pdf new file mode 100644 index 000000000..8e6427c01 --- /dev/null +++ b/assets/acroform-import-rotated-180.pdf @@ -0,0 +1,717 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< +/Type /Pages +/Kids [4 0 R 5 0 R] +/Count 2 +>> +endobj +2 0 obj +<< +/Type /Catalog +/Pages 1 0 R +/AcroForm 6 0 R +>> +endobj +3 0 obj +<< +/Title (Untitled) +/Author (Unknown) +/Creator (@libpdf/core) +/Producer (@libpdf/core) +/CreationDate (D:20260521033345Z) +/ModDate (D:20260521033345Z) +>> +endobj +4 0 obj +<< +/Type /Page +/MediaBox [0 0 612 792] +/Resources << +>> +/Rotate 180 +/Parent 1 0 R +/Annots [8 0 R 11 0 R 14 0 R] +>> +endobj +5 0 obj +<< +/Type /Page +/MediaBox [0 0 612 792] +/Resources << +>> +/Rotate 180 +/Parent 1 0 R +/Annots [18 0 R 21 0 R 24 0 R 27 0 R 31 0 R 34 0 R 37 0 R] +>> +endobj +6 0 obj +<< +/Fields [7 0 R 10 0 R 13 0 R 17 0 R 20 0 R 30 0 R 33 0 R 36 0 R] +/DR << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +/DA (/Helv 0 Tf 0 g) +/NeedAppearances false +/SigFlags 3 +>> +endobj +7 0 obj +<< +/FT /Tx +/T (CustomerName) +/Kids [8 0 R] +/DA (0 g) +>> +endobj +8 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 620 280 644] +/P 4 0 R +/Parent 7 0 R +/F 4 +/AP << +/N 9 0 R +>> +>> +endobj +9 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 198 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +10 0 obj +<< +/FT /Tx +/T (signed_date) +/Kids [11 0 R] +/DA (0 g) +>> +endobj +11 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 560 280 584] +/P 4 0 R +/Parent 10 0 R +/F 4 +/AP << +/N 12 0 R +>> +>> +endobj +12 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 198 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +13 0 obj +<< +/FT /Btn +/T (accept_terms) +/Kids [14 0 R] +/V /Off +>> +endobj +14 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 500 98 518] +/P 4 0 R +/Parent 13 0 R +/F 4 +/AS /Off +/AP << +/N << +/Yes 15 0 R +/Off 16 0 R +>> +>> +>> +endobj +15 0 obj +<< +/Length 47/Type /XObject +/Subtype /Form +/BBox [0 0 18 18] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 12.6 Tf +0 g +2.7 4.59 Td +(4) Tj +ET +Q + +endstream +endobj +16 0 obj +<< +/Length 0/Type /XObject +/Subtype /Form +/BBox [0 0 18 18] +/Resources << +>> +>> +stream + +endstream +endobj +17 0 obj +<< +/FT /Ch +/T (country) +/Ff 131072 +/Kids [18 0 R] +/Opt [(USA) (Canada) (Germany)] +/DA (0 g) +/V (USA) +/DV (USA) +>> +endobj +18 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 700 280 724] +/P 5 0 R +/Parent 17 0 R +/F 4 +/AP << +/N 19 0 R +>> +>> +endobj +19 0 obj +<< +/Length 76/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 178 22 re +W +n +BT +/Helv 14 Tf +0 g +2 9.872 Td +(USA) Tj +ET +Q +EMC + +endstream +endobj +20 0 obj +<< +/FT /Btn +/T (payment_method) +/Ff 32768 +/Kids [21 0 R 24 0 R 27 0 R] +/V /PayPal +/DV /PayPal +/Opt [(Credit Card) (PayPal) (Bank Transfer)] +>> +endobj +21 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 640 96 656] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /Off +/AP << +/N << +/Credit#20Card 22 0 R +/Off 23 0 R +>> +>> +>> +endobj +22 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +23 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +24 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 615 96 631] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /PayPal +/AP << +/N << +/PayPal 25 0 R +/Off 26 0 R +>> +>> +>> +endobj +25 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +26 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +27 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 590 96 606] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /Off +/AP << +/N << +/Bank#20Transfer 28 0 R +/Off 29 0 R +>> +>> +>> +endobj +28 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +29 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +30 0 obj +<< +/FT /Tx +/T (initials) +/Kids [31 0 R] +/DA (0 g) +>> +endobj +31 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 540 140 564] +/P 5 0 R +/Parent 30 0 R +/F 4 +/AP << +/N 32 0 R +>> +>> +endobj +32 0 obj +<< +/Length 72/Type /XObject +/Subtype /Form +/BBox [0 0 60 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 58 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +33 0 obj +<< +/FT /Tx +/T (contact_email) +/Kids [34 0 R] +/DA (0 g) +>> +endobj +34 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [160 540 380 564] +/P 5 0 R +/Parent 33 0 R +/F 4 +/AP << +/N 35 0 R +>> +>> +endobj +35 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 220 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 218 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +36 0 obj +<< +/FT /Tx +/T (item_qty) +/Kids [37 0 R] +/DA (0 g) +/MaxLen 4 +>> +endobj +37 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [400 540 460 564] +/P 5 0 R +/Parent 36 0 R +/F 4 +/AP << +/N 38 0 R +>> +>> +endobj +38 0 obj +<< +/Length 72/Type /XObject +/Subtype /Form +/BBox [0 0 60 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 58 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +xref +0 39 +0000000000 65535 f +0000000015 00000 n +0000000078 00000 n +0000000143 00000 n +0000000312 00000 n +0000000442 00000 n +0000000601 00000 n +0000000890 00000 n +0000000961 00000 n +0000001082 00000 n +0000001343 00000 n +0000001415 00000 n +0000001539 00000 n +0000001801 00000 n +0000001873 00000 n +0000002028 00000 n +0000002266 00000 n +0000002380 00000 n +0000002510 00000 n +0000002634 00000 n +0000002899 00000 n +0000003058 00000 n +0000003223 00000 n +0000003460 00000 n +0000003735 00000 n +0000003896 00000 n +0000004133 00000 n +0000004408 00000 n +0000004575 00000 n +0000004812 00000 n +0000005087 00000 n +0000005156 00000 n +0000005280 00000 n +0000005540 00000 n +0000005614 00000 n +0000005739 00000 n +0000006001 00000 n +0000006080 00000 n +0000006205 00000 n +trailer +<< +/Size 39 +/Root 2 0 R +/Info 3 0 R +/ID [<6012AA895DABB8C2373E5EF0CB137849> <6012AA895DABB8C2373E5EF0CB137849>] +>> +startxref +6465 +%%EOF diff --git a/assets/acroform-import-rotated-270.pdf b/assets/acroform-import-rotated-270.pdf new file mode 100644 index 000000000..e488c6acc --- /dev/null +++ b/assets/acroform-import-rotated-270.pdf @@ -0,0 +1,717 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< +/Type /Pages +/Kids [4 0 R 5 0 R] +/Count 2 +>> +endobj +2 0 obj +<< +/Type /Catalog +/Pages 1 0 R +/AcroForm 6 0 R +>> +endobj +3 0 obj +<< +/Title (Untitled) +/Author (Unknown) +/Creator (@libpdf/core) +/Producer (@libpdf/core) +/CreationDate (D:20260521033345Z) +/ModDate (D:20260521033345Z) +>> +endobj +4 0 obj +<< +/Type /Page +/MediaBox [0 0 612 792] +/Resources << +>> +/Rotate 270 +/Parent 1 0 R +/Annots [8 0 R 11 0 R 14 0 R] +>> +endobj +5 0 obj +<< +/Type /Page +/MediaBox [0 0 612 792] +/Resources << +>> +/Rotate 270 +/Parent 1 0 R +/Annots [18 0 R 21 0 R 24 0 R 27 0 R 31 0 R 34 0 R 37 0 R] +>> +endobj +6 0 obj +<< +/Fields [7 0 R 10 0 R 13 0 R 17 0 R 20 0 R 30 0 R 33 0 R 36 0 R] +/DR << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +/DA (/Helv 0 Tf 0 g) +/NeedAppearances false +/SigFlags 3 +>> +endobj +7 0 obj +<< +/FT /Tx +/T (CustomerName) +/Kids [8 0 R] +/DA (0 g) +>> +endobj +8 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 620 280 644] +/P 4 0 R +/Parent 7 0 R +/F 4 +/AP << +/N 9 0 R +>> +>> +endobj +9 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 198 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +10 0 obj +<< +/FT /Tx +/T (signed_date) +/Kids [11 0 R] +/DA (0 g) +>> +endobj +11 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 560 280 584] +/P 4 0 R +/Parent 10 0 R +/F 4 +/AP << +/N 12 0 R +>> +>> +endobj +12 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 198 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +13 0 obj +<< +/FT /Btn +/T (accept_terms) +/Kids [14 0 R] +/V /Off +>> +endobj +14 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 500 98 518] +/P 4 0 R +/Parent 13 0 R +/F 4 +/AS /Off +/AP << +/N << +/Yes 15 0 R +/Off 16 0 R +>> +>> +>> +endobj +15 0 obj +<< +/Length 47/Type /XObject +/Subtype /Form +/BBox [0 0 18 18] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 12.6 Tf +0 g +2.7 4.59 Td +(4) Tj +ET +Q + +endstream +endobj +16 0 obj +<< +/Length 0/Type /XObject +/Subtype /Form +/BBox [0 0 18 18] +/Resources << +>> +>> +stream + +endstream +endobj +17 0 obj +<< +/FT /Ch +/T (country) +/Ff 131072 +/Kids [18 0 R] +/Opt [(USA) (Canada) (Germany)] +/DA (0 g) +/V (USA) +/DV (USA) +>> +endobj +18 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 700 280 724] +/P 5 0 R +/Parent 17 0 R +/F 4 +/AP << +/N 19 0 R +>> +>> +endobj +19 0 obj +<< +/Length 76/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 178 22 re +W +n +BT +/Helv 14 Tf +0 g +2 9.872 Td +(USA) Tj +ET +Q +EMC + +endstream +endobj +20 0 obj +<< +/FT /Btn +/T (payment_method) +/Ff 32768 +/Kids [21 0 R 24 0 R 27 0 R] +/V /PayPal +/DV /PayPal +/Opt [(Credit Card) (PayPal) (Bank Transfer)] +>> +endobj +21 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 640 96 656] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /Off +/AP << +/N << +/Credit#20Card 22 0 R +/Off 23 0 R +>> +>> +>> +endobj +22 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +23 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +24 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 615 96 631] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /PayPal +/AP << +/N << +/PayPal 25 0 R +/Off 26 0 R +>> +>> +>> +endobj +25 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +26 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +27 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 590 96 606] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /Off +/AP << +/N << +/Bank#20Transfer 28 0 R +/Off 29 0 R +>> +>> +>> +endobj +28 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +29 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +30 0 obj +<< +/FT /Tx +/T (initials) +/Kids [31 0 R] +/DA (0 g) +>> +endobj +31 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 540 140 564] +/P 5 0 R +/Parent 30 0 R +/F 4 +/AP << +/N 32 0 R +>> +>> +endobj +32 0 obj +<< +/Length 72/Type /XObject +/Subtype /Form +/BBox [0 0 60 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 58 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +33 0 obj +<< +/FT /Tx +/T (contact_email) +/Kids [34 0 R] +/DA (0 g) +>> +endobj +34 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [160 540 380 564] +/P 5 0 R +/Parent 33 0 R +/F 4 +/AP << +/N 35 0 R +>> +>> +endobj +35 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 220 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 218 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +36 0 obj +<< +/FT /Tx +/T (item_qty) +/Kids [37 0 R] +/DA (0 g) +/MaxLen 4 +>> +endobj +37 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [400 540 460 564] +/P 5 0 R +/Parent 36 0 R +/F 4 +/AP << +/N 38 0 R +>> +>> +endobj +38 0 obj +<< +/Length 72/Type /XObject +/Subtype /Form +/BBox [0 0 60 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 58 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +xref +0 39 +0000000000 65535 f +0000000015 00000 n +0000000078 00000 n +0000000143 00000 n +0000000312 00000 n +0000000442 00000 n +0000000601 00000 n +0000000890 00000 n +0000000961 00000 n +0000001082 00000 n +0000001343 00000 n +0000001415 00000 n +0000001539 00000 n +0000001801 00000 n +0000001873 00000 n +0000002028 00000 n +0000002266 00000 n +0000002380 00000 n +0000002510 00000 n +0000002634 00000 n +0000002899 00000 n +0000003058 00000 n +0000003223 00000 n +0000003460 00000 n +0000003735 00000 n +0000003896 00000 n +0000004133 00000 n +0000004408 00000 n +0000004575 00000 n +0000004812 00000 n +0000005087 00000 n +0000005156 00000 n +0000005280 00000 n +0000005540 00000 n +0000005614 00000 n +0000005739 00000 n +0000006001 00000 n +0000006080 00000 n +0000006205 00000 n +trailer +<< +/Size 39 +/Root 2 0 R +/Info 3 0 R +/ID [<024D70B7251C83CF3A243EA11D6F5FC6> <024D70B7251C83CF3A243EA11D6F5FC6>] +>> +startxref +6465 +%%EOF diff --git a/assets/acroform-import-rotated-90.pdf b/assets/acroform-import-rotated-90.pdf new file mode 100644 index 000000000..cdf0ea464 --- /dev/null +++ b/assets/acroform-import-rotated-90.pdf @@ -0,0 +1,717 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< +/Type /Pages +/Kids [4 0 R 5 0 R] +/Count 2 +>> +endobj +2 0 obj +<< +/Type /Catalog +/Pages 1 0 R +/AcroForm 6 0 R +>> +endobj +3 0 obj +<< +/Title (Untitled) +/Author (Unknown) +/Creator (@libpdf/core) +/Producer (@libpdf/core) +/CreationDate (D:20260521033345Z) +/ModDate (D:20260521033345Z) +>> +endobj +4 0 obj +<< +/Type /Page +/MediaBox [0 0 612 792] +/Resources << +>> +/Rotate 90 +/Parent 1 0 R +/Annots [8 0 R 11 0 R 14 0 R] +>> +endobj +5 0 obj +<< +/Type /Page +/MediaBox [0 0 612 792] +/Resources << +>> +/Rotate 90 +/Parent 1 0 R +/Annots [18 0 R 21 0 R 24 0 R 27 0 R 31 0 R 34 0 R 37 0 R] +>> +endobj +6 0 obj +<< +/Fields [7 0 R 10 0 R 13 0 R 17 0 R 20 0 R 30 0 R 33 0 R 36 0 R] +/DR << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +/DA (/Helv 0 Tf 0 g) +/NeedAppearances false +/SigFlags 3 +>> +endobj +7 0 obj +<< +/FT /Tx +/T (CustomerName) +/Kids [8 0 R] +/DA (0 g) +>> +endobj +8 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 620 280 644] +/P 4 0 R +/Parent 7 0 R +/F 4 +/AP << +/N 9 0 R +>> +>> +endobj +9 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 198 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +10 0 obj +<< +/FT /Tx +/T (signed_date) +/Kids [11 0 R] +/DA (0 g) +>> +endobj +11 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 560 280 584] +/P 4 0 R +/Parent 10 0 R +/F 4 +/AP << +/N 12 0 R +>> +>> +endobj +12 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 198 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +13 0 obj +<< +/FT /Btn +/T (accept_terms) +/Kids [14 0 R] +/V /Off +>> +endobj +14 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 500 98 518] +/P 4 0 R +/Parent 13 0 R +/F 4 +/AS /Off +/AP << +/N << +/Yes 15 0 R +/Off 16 0 R +>> +>> +>> +endobj +15 0 obj +<< +/Length 47/Type /XObject +/Subtype /Form +/BBox [0 0 18 18] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 12.6 Tf +0 g +2.7 4.59 Td +(4) Tj +ET +Q + +endstream +endobj +16 0 obj +<< +/Length 0/Type /XObject +/Subtype /Form +/BBox [0 0 18 18] +/Resources << +>> +>> +stream + +endstream +endobj +17 0 obj +<< +/FT /Ch +/T (country) +/Ff 131072 +/Kids [18 0 R] +/Opt [(USA) (Canada) (Germany)] +/DA (0 g) +/V (USA) +/DV (USA) +>> +endobj +18 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 700 280 724] +/P 5 0 R +/Parent 17 0 R +/F 4 +/AP << +/N 19 0 R +>> +>> +endobj +19 0 obj +<< +/Length 76/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 178 22 re +W +n +BT +/Helv 14 Tf +0 g +2 9.872 Td +(USA) Tj +ET +Q +EMC + +endstream +endobj +20 0 obj +<< +/FT /Btn +/T (payment_method) +/Ff 32768 +/Kids [21 0 R 24 0 R 27 0 R] +/V /PayPal +/DV /PayPal +/Opt [(Credit Card) (PayPal) (Bank Transfer)] +>> +endobj +21 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 640 96 656] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /Off +/AP << +/N << +/Credit#20Card 22 0 R +/Off 23 0 R +>> +>> +>> +endobj +22 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +23 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +24 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 615 96 631] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /PayPal +/AP << +/N << +/PayPal 25 0 R +/Off 26 0 R +>> +>> +>> +endobj +25 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +26 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +27 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 590 96 606] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /Off +/AP << +/N << +/Bank#20Transfer 28 0 R +/Off 29 0 R +>> +>> +>> +endobj +28 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +29 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +30 0 obj +<< +/FT /Tx +/T (initials) +/Kids [31 0 R] +/DA (0 g) +>> +endobj +31 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 540 140 564] +/P 5 0 R +/Parent 30 0 R +/F 4 +/AP << +/N 32 0 R +>> +>> +endobj +32 0 obj +<< +/Length 72/Type /XObject +/Subtype /Form +/BBox [0 0 60 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 58 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +33 0 obj +<< +/FT /Tx +/T (contact_email) +/Kids [34 0 R] +/DA (0 g) +>> +endobj +34 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [160 540 380 564] +/P 5 0 R +/Parent 33 0 R +/F 4 +/AP << +/N 35 0 R +>> +>> +endobj +35 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 220 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 218 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +36 0 obj +<< +/FT /Tx +/T (item_qty) +/Kids [37 0 R] +/DA (0 g) +/MaxLen 4 +>> +endobj +37 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [400 540 460 564] +/P 5 0 R +/Parent 36 0 R +/F 4 +/AP << +/N 38 0 R +>> +>> +endobj +38 0 obj +<< +/Length 72/Type /XObject +/Subtype /Form +/BBox [0 0 60 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 58 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +xref +0 39 +0000000000 65535 f +0000000015 00000 n +0000000078 00000 n +0000000143 00000 n +0000000312 00000 n +0000000441 00000 n +0000000599 00000 n +0000000888 00000 n +0000000959 00000 n +0000001080 00000 n +0000001341 00000 n +0000001413 00000 n +0000001537 00000 n +0000001799 00000 n +0000001871 00000 n +0000002026 00000 n +0000002264 00000 n +0000002378 00000 n +0000002508 00000 n +0000002632 00000 n +0000002897 00000 n +0000003056 00000 n +0000003221 00000 n +0000003458 00000 n +0000003733 00000 n +0000003894 00000 n +0000004131 00000 n +0000004406 00000 n +0000004573 00000 n +0000004810 00000 n +0000005085 00000 n +0000005154 00000 n +0000005278 00000 n +0000005538 00000 n +0000005612 00000 n +0000005737 00000 n +0000005999 00000 n +0000006078 00000 n +0000006203 00000 n +trailer +<< +/Size 39 +/Root 2 0 R +/Info 3 0 R +/ID [<28A7D9B2A870BB05CFF56CD76F06FC33> <28A7D9B2A870BB05CFF56CD76F06FC33>] +>> +startxref +6463 +%%EOF diff --git a/assets/acroform-import-test.pdf b/assets/acroform-import-test.pdf new file mode 100644 index 000000000..73f3654aa --- /dev/null +++ b/assets/acroform-import-test.pdf @@ -0,0 +1,715 @@ +%PDF-1.7 +%âãÏÓ +1 0 obj +<< +/Type /Pages +/Kids [4 0 R 5 0 R] +/Count 2 +>> +endobj +2 0 obj +<< +/Type /Catalog +/Pages 1 0 R +/AcroForm 6 0 R +>> +endobj +3 0 obj +<< +/Title (Untitled) +/Author (Unknown) +/Creator (@libpdf/core) +/Producer (@libpdf/core) +/CreationDate (D:20260521033345Z) +/ModDate (D:20260521033345Z) +>> +endobj +4 0 obj +<< +/Type /Page +/MediaBox [0 0 612 792] +/Resources << +>> +/Parent 1 0 R +/Annots [8 0 R 11 0 R 14 0 R] +>> +endobj +5 0 obj +<< +/Type /Page +/MediaBox [0 0 612 792] +/Resources << +>> +/Parent 1 0 R +/Annots [18 0 R 21 0 R 24 0 R 27 0 R 31 0 R 34 0 R 37 0 R] +>> +endobj +6 0 obj +<< +/Fields [7 0 R 10 0 R 13 0 R 17 0 R 20 0 R 30 0 R 33 0 R 36 0 R] +/DR << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +/DA (/Helv 0 Tf 0 g) +/NeedAppearances false +/SigFlags 3 +>> +endobj +7 0 obj +<< +/FT /Tx +/T (CustomerName) +/Kids [8 0 R] +/DA (0 g) +>> +endobj +8 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 620 280 644] +/P 4 0 R +/Parent 7 0 R +/F 4 +/AP << +/N 9 0 R +>> +>> +endobj +9 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 198 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +10 0 obj +<< +/FT /Tx +/T (signed_date) +/Kids [11 0 R] +/DA (0 g) +>> +endobj +11 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 560 280 584] +/P 4 0 R +/Parent 10 0 R +/F 4 +/AP << +/N 12 0 R +>> +>> +endobj +12 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 198 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +13 0 obj +<< +/FT /Btn +/T (accept_terms) +/Kids [14 0 R] +/V /Off +>> +endobj +14 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 500 98 518] +/P 4 0 R +/Parent 13 0 R +/F 4 +/AS /Off +/AP << +/N << +/Yes 15 0 R +/Off 16 0 R +>> +>> +>> +endobj +15 0 obj +<< +/Length 47/Type /XObject +/Subtype /Form +/BBox [0 0 18 18] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 12.6 Tf +0 g +2.7 4.59 Td +(4) Tj +ET +Q + +endstream +endobj +16 0 obj +<< +/Length 0/Type /XObject +/Subtype /Form +/BBox [0 0 18 18] +/Resources << +>> +>> +stream + +endstream +endobj +17 0 obj +<< +/FT /Ch +/T (country) +/Ff 131072 +/Kids [18 0 R] +/Opt [(USA) (Canada) (Germany)] +/DA (0 g) +/V (USA) +/DV (USA) +>> +endobj +18 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 700 280 724] +/P 5 0 R +/Parent 17 0 R +/F 4 +/AP << +/N 19 0 R +>> +>> +endobj +19 0 obj +<< +/Length 76/Type /XObject +/Subtype /Form +/BBox [0 0 200 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 178 22 re +W +n +BT +/Helv 14 Tf +0 g +2 9.872 Td +(USA) Tj +ET +Q +EMC + +endstream +endobj +20 0 obj +<< +/FT /Btn +/T (payment_method) +/Ff 32768 +/Kids [21 0 R 24 0 R 27 0 R] +/V /PayPal +/DV /PayPal +/Opt [(Credit Card) (PayPal) (Bank Transfer)] +>> +endobj +21 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 640 96 656] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /Off +/AP << +/N << +/Credit#20Card 22 0 R +/Off 23 0 R +>> +>> +>> +endobj +22 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +23 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +24 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 615 96 631] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /PayPal +/AP << +/N << +/PayPal 25 0 R +/Off 26 0 R +>> +>> +>> +endobj +25 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +26 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +27 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 590 96 606] +/P 5 0 R +/Parent 20 0 R +/F 4 +/AS /Off +/AP << +/N << +/Bank#20Transfer 28 0 R +/Off 29 0 R +>> +>> +>> +endobj +28 0 obj +<< +/Length 46/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +/Font << +/ZaDb << +/Type /Font +/Subtype /Type1 +/BaseFont /ZapfDingbats +>> +>> +>> +>> +stream +q +BT +/ZaDb 9.6 Tf +0 g +3.2 4.64 Td +(l) Tj +ET +Q + +endstream +endobj +29 0 obj +<< +/Length 159/Type /XObject +/Subtype /Form +/BBox [0 0 16 16] +/Resources << +>> +>> +stream +q +0 G +12.8 8 m +12.8 10.65104 10.65104 12.8 8 12.8 c +5.34896 12.8 3.2 10.65104 3.2 8 c +3.2 5.34896 5.34896 3.2 8 3.2 c +10.65104 3.2 12.8 5.34896 12.8 8 c +h +S +Q + +endstream +endobj +30 0 obj +<< +/FT /Tx +/T (initials) +/Kids [31 0 R] +/DA (0 g) +>> +endobj +31 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [80 540 140 564] +/P 5 0 R +/Parent 30 0 R +/F 4 +/AP << +/N 32 0 R +>> +>> +endobj +32 0 obj +<< +/Length 72/Type /XObject +/Subtype /Form +/BBox [0 0 60 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 58 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +33 0 obj +<< +/FT /Tx +/T (contact_email) +/Kids [34 0 R] +/DA (0 g) +>> +endobj +34 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [160 540 380 564] +/P 5 0 R +/Parent 33 0 R +/F 4 +/AP << +/N 35 0 R +>> +>> +endobj +35 0 obj +<< +/Length 73/Type /XObject +/Subtype /Form +/BBox [0 0 220 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 218 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +36 0 obj +<< +/FT /Tx +/T (item_qty) +/Kids [37 0 R] +/DA (0 g) +/MaxLen 4 +>> +endobj +37 0 obj +<< +/Type /Annot +/Subtype /Widget +/Rect [400 540 460 564] +/P 5 0 R +/Parent 36 0 R +/F 4 +/AP << +/N 38 0 R +>> +>> +endobj +38 0 obj +<< +/Length 72/Type /XObject +/Subtype /Form +/BBox [0 0 60 24] +/Resources << +/Font << +/Helv << +/Type /Font +/Subtype /Type1 +/BaseFont /Helvetica +>> +>> +>> +>> +stream +/Tx BMC +q +1 1 58 22 re +W +n +BT +/Helv 14 Tf +0 g +2 6.974 Td +() Tj +ET +Q +EMC + +endstream +endobj +xref +0 39 +0000000000 65535 f +0000000015 00000 n +0000000078 00000 n +0000000143 00000 n +0000000312 00000 n +0000000430 00000 n +0000000577 00000 n +0000000866 00000 n +0000000937 00000 n +0000001058 00000 n +0000001319 00000 n +0000001391 00000 n +0000001515 00000 n +0000001777 00000 n +0000001849 00000 n +0000002004 00000 n +0000002242 00000 n +0000002356 00000 n +0000002486 00000 n +0000002610 00000 n +0000002875 00000 n +0000003034 00000 n +0000003199 00000 n +0000003436 00000 n +0000003711 00000 n +0000003872 00000 n +0000004109 00000 n +0000004384 00000 n +0000004551 00000 n +0000004788 00000 n +0000005063 00000 n +0000005132 00000 n +0000005256 00000 n +0000005516 00000 n +0000005590 00000 n +0000005715 00000 n +0000005977 00000 n +0000006056 00000 n +0000006181 00000 n +trailer +<< +/Size 39 +/Root 2 0 R +/Info 3 0 R +/ID [<4798EA6ACCB2CE19827C36CD841F759A> <4798EA6ACCB2CE19827C36CD841F759A>] +>> +startxref +6441 +%%EOF diff --git a/packages/app-tests/e2e/scenarios/acroform-import.spec.ts b/packages/app-tests/e2e/scenarios/acroform-import.spec.ts new file mode 100644 index 000000000..52cde4ac1 --- /dev/null +++ b/packages/app-tests/e2e/scenarios/acroform-import.spec.ts @@ -0,0 +1,130 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { EnvelopeType, RecipientRole } from '@documenso/prisma/client'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { + TCreateEnvelopePayload, + TCreateEnvelopeResponse, +} from '@documenso/trpc/server/envelope-router/create-envelope.types'; +import { expect, test } from '@playwright/test'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; + +const ACROFORM_FIXTURE = fs.readFileSync(path.join(__dirname, '../../../../assets/acroform-import-test.pdf')); + +test.describe.configure({ + mode: 'parallel', +}); + +test.describe('AcroForm Import', () => { + test('imports AcroForm widgets as Documenso fields assigned to the provided signer', async ({ request }) => { + const { user, team } = await seedUser(); + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const payload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'AcroForm document', + recipients: [ + { + email: 'signer@example.com', + name: 'Signer', + role: RecipientRole.SIGNER, + }, + ], + }; + + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + formData.append('files', new File([ACROFORM_FIXTURE], 'acroform-import-test.pdf', { type: 'application/pdf' })); + + const res = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(res.ok()).toBeTruthy(); + + const response = (await res.json()) as TCreateEnvelopeResponse; + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: response.id }, + include: { + envelopeItems: { include: { documentData: true } }, + recipients: true, + fields: true, + }, + }); + + expect(envelope.recipients).toHaveLength(1); + expect(envelope.recipients[0].email).toBe('signer@example.com'); + + // Every imported field is assigned to the single signer. + expect(envelope.fields.length).toBeGreaterThanOrEqual(8); + expect(envelope.fields.every((f) => f.recipientId === envelope.recipients[0].id)).toBe(true); + + // Every imported field has source: 'acroform' on its fieldMeta. + for (const field of envelope.fields) { + const meta = field.fieldMeta as { source?: string } | null; + expect(meta?.source).toBe('acroform'); + } + + // FIELD_CREATED audit log entries match the number of imported fields. + const auditEntries = await prisma.documentAuditLog.findMany({ + where: { envelopeId: envelope.id, type: 'FIELD_CREATED' }, + }); + + expect(auditEntries.length).toBe(envelope.fields.length); + }); + + test('creates a placeholder Recipient 1 SIGNER when no recipients are provided', async ({ request }) => { + const { user, team } = await seedUser(); + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'test', + expiresIn: null, + }); + + const payload: TCreateEnvelopePayload = { + type: EnvelopeType.DOCUMENT, + title: 'AcroForm document without recipients', + }; + + const formData = new FormData(); + formData.append('payload', JSON.stringify(payload)); + formData.append('files', new File([ACROFORM_FIXTURE], 'acroform-import-test.pdf', { type: 'application/pdf' })); + + const res = await request.post(`${baseUrl}/envelope/create`, { + headers: { Authorization: `Bearer ${token}` }, + multipart: formData, + }); + + expect(res.ok()).toBeTruthy(); + + const response = (await res.json()) as TCreateEnvelopeResponse; + + const envelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: response.id }, + include: { + recipients: true, + fields: true, + }, + }); + + expect(envelope.recipients).toHaveLength(1); + expect(envelope.recipients[0].email).toBe('recipient.1@documenso.com'); + expect(envelope.recipients[0].role).toBe(RecipientRole.SIGNER); + expect(envelope.fields.length).toBeGreaterThanOrEqual(8); + expect(envelope.fields.every((f) => f.recipientId === envelope.recipients[0].id)).toBe(true); + }); +}); diff --git a/packages/lib/server-only/envelope-item/create-envelope-items.ts b/packages/lib/server-only/envelope-item/create-envelope-items.ts index b4e5dda38..16ea2014d 100644 --- a/packages/lib/server-only/envelope-item/create-envelope-items.ts +++ b/packages/lib/server-only/envelope-item/create-envelope-items.ts @@ -1,3 +1,7 @@ +import { + convertAcroFormFieldsToFieldInputs, + extractAcroFormFieldsFromPDF, +} from '@documenso/lib/server-only/pdf/acroform-fields'; import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders, @@ -10,8 +14,10 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques import { prefixedId } from '@documenso/lib/universal/id'; import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { logger } from '@documenso/lib/utils/logger'; import { prisma } from '@documenso/prisma'; import type { Envelope, EnvelopeItem, Recipient } from '@prisma/client'; +import { RecipientRole } from '@prisma/client'; type UnsafeCreateEnvelopeItemsOptions = { files: { @@ -44,7 +50,8 @@ export const UNSAFE_createEnvelopeItems = async ({ }: UnsafeCreateEnvelopeItemsOptions) => { const currentHighestOrderValue = envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1; - // For each file: normalize, extract & clean placeholders, then upload. + // For each file: extract AcroForm widgets, normalize, extract & clean + // placeholders, then upload. const envelopeItemsToCreate = await Promise.all( files.map(async ({ file, orderOverride, clientId }, index) => { let buffer = Buffer.from(await file.arrayBuffer()); @@ -53,8 +60,55 @@ export const UNSAFE_createEnvelopeItems = async ({ buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues }); } + // Run AcroForm extraction BEFORE normalizePdf — flattening destroys + // widget geometry, which we need to reuse as Documenso fields. + const acroFormExtraction = await extractAcroFormFieldsFromPDF(buffer, { + formValuesProvided: Boolean(envelope.formValues), + }); + + if (acroFormExtraction.skipReason) { + logger.info( + { + event: 'acroform-import.skip', + envelopeItemTitle: file.name, + reason: acroFormExtraction.skipReason, + }, + 'AcroForm extraction skipped', + ); + } + + if (acroFormExtraction.unsupported.length > 0) { + const byReason: Record = {}; + + for (const entry of acroFormExtraction.unsupported) { + byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1; + } + + logger.info( + { + event: 'acroform-import.unsupported', + envelopeItemTitle: file.name, + count: acroFormExtraction.unsupported.length, + byReason, + }, + 'AcroForm import skipped unsupported widgets', + ); + } + + if (acroFormExtraction.hasSignedSignature) { + logger.warn( + { + event: 'acroform-import.signed-pdf-no-flatten', + envelopeItemTitle: file.name, + }, + 'Signed AcroForm signature detected — skipping flatten to preserve signature', + ); + } + + const shouldFlatten = envelope.type !== 'TEMPLATE' && !acroFormExtraction.hasSignedSignature; + const normalized = await normalizePdf(buffer, { - flattenForm: envelope.type !== 'TEMPLATE', + flattenForm: shouldFlatten, }); const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized); @@ -71,6 +125,7 @@ export const UNSAFE_createEnvelopeItems = async ({ clientId, documentDataId: documentData.id, placeholders, + acroFormFields: acroFormExtraction.fields, order: orderOverride ?? currentHighestOrderValue + index + 1, }; }), @@ -158,6 +213,75 @@ export const UNSAFE_createEnvelopeItems = async ({ }); } } + + const pickFirstSignableRecipient = () => { + const signable = orderedRecipients.filter( + (r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER, + ); + + return signable[0] ?? null; + }; + + const firstSignableRecipient = pickFirstSignableRecipient(); + + if (firstSignableRecipient) { + for (const uploadedItem of envelopeItemsToCreate) { + if (!uploadedItem.acroFormFields || uploadedItem.acroFormFields.length === 0) { + continue; + } + + const createdItem = createdItems.find((ci) => ci.documentDataId === uploadedItem.documentDataId); + + if (!createdItem) { + continue; + } + + const acroFormFieldsToCreate = convertAcroFormFieldsToFieldInputs( + uploadedItem.acroFormFields, + () => firstSignableRecipient, + createdItem.id, + ); + + if (acroFormFieldsToCreate.length === 0) { + continue; + } + + const createdFields = await tx.field.createManyAndReturn({ + data: acroFormFieldsToCreate.map((field) => ({ + envelopeId: envelope.id, + envelopeItemId: createdItem.id, + recipientId: field.recipientId, + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: '', + inserted: false, + fieldMeta: field.fieldMeta || undefined, + })), + }); + + if (envelope.type === 'DOCUMENT') { + await tx.documentAuditLog.createMany({ + data: createdFields.map((createdField) => + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED, + envelopeId: envelope.id, + metadata: apiRequestMetadata, + data: { + fieldId: createdField.secondaryId, + fieldRecipientEmail: firstSignableRecipient.email, + fieldRecipientId: createdField.recipientId, + fieldType: createdField.type, + }, + }), + ), + }); + } + } + } } return createdItems.map((item) => { diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 37c221b90..6474cfc44 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -1,4 +1,6 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { AcroFormFieldImportInfo } from '@documenso/lib/server-only/pdf/acroform-fields'; +import { convertAcroFormFieldsToFieldInputs } from '@documenso/lib/server-only/pdf/acroform-fields'; import type { PlaceholderInfo } from '@documenso/lib/server-only/pdf/auto-place-fields'; import { convertPlaceholdersToFieldInputs } from '@documenso/lib/server-only/pdf/auto-place-fields'; import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers'; @@ -72,6 +74,7 @@ export type CreateEnvelopeOptions = { documentDataId: string; order?: number; placeholders?: PlaceholderInfo[]; + acroFormFields?: AcroFormFieldImportInfo[]; }[]; formValues?: TDocumentFormValues; @@ -537,6 +540,136 @@ export const createEnvelope = async ({ } } + // Create fields from imported AcroForm widgets (extracted at upload time). + // Runs after the placeholder branch so placeholder-created recipients are + // visible in `availableRecipientsForAcroForm`. + const itemsWithAcroFormFields = envelopeItems.filter( + (item) => item.acroFormFields && item.acroFormFields.length > 0, + ); + + if (itemsWithAcroFormFields.length > 0) { + let availableRecipientsForAcroForm = await tx.recipient.findMany({ + where: { envelopeId: envelope.id }, + select: { id: true, email: true, role: true, signingOrder: true }, + }); + + const pickFirstSignableRecipient = () => { + const signable = availableRecipientsForAcroForm.filter( + (r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER, + ); + + if (signable.length === 0) { + return null; + } + + return [...signable].sort((a, b) => { + const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER; + const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER; + + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + + return a.id - b.id; + })[0]; + }; + + let firstSignableRecipient = pickFirstSignableRecipient(); + + if (!firstSignableRecipient) { + // No signable recipient yet — create a placeholder Recipient 1 SIGNER + // mirroring the placeholder branch's behavior. + const placeholderEmail = 'recipient.1@documenso.com'; + const existingPlaceholder = availableRecipientsForAcroForm.find( + (r) => r.email.toLowerCase() === placeholderEmail, + ); + + if (!existingPlaceholder) { + await tx.recipient.create({ + data: { + envelopeId: envelope.id, + email: placeholderEmail, + name: 'Recipient 1', + role: RecipientRole.SIGNER, + signingOrder: 1, + token: nanoid(), + sendStatus: SendStatus.NOT_SENT, + signingStatus: SigningStatus.NOT_SIGNED, + }, + }); + } + + // eslint-disable-next-line require-atomic-updates + availableRecipientsForAcroForm = await tx.recipient.findMany({ + where: { envelopeId: envelope.id }, + select: { id: true, email: true, role: true, signingOrder: true }, + }); + + firstSignableRecipient = pickFirstSignableRecipient(); + } + + if (!firstSignableRecipient) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Could not resolve a signable recipient for AcroForm import.', + }); + } + + const acroFormRecipient = firstSignableRecipient; + + for (const item of itemsWithAcroFormFields) { + const envelopeItem = envelope.envelopeItems.find((ei) => ei.documentDataId === item.documentDataId); + + if (!envelopeItem) { + continue; + } + + const fieldsToCreate = convertAcroFormFieldsToFieldInputs( + item.acroFormFields ?? [], + () => acroFormRecipient, + envelopeItem.id, + ); + + if (fieldsToCreate.length === 0) { + continue; + } + + const createdFields = await tx.field.createManyAndReturn({ + data: fieldsToCreate.map((field) => ({ + envelopeId: envelope.id, + envelopeItemId: envelopeItem.id, + recipientId: field.recipientId, + type: field.type, + page: field.page, + positionX: field.positionX, + positionY: field.positionY, + width: field.width, + height: field.height, + customText: '', + inserted: false, + fieldMeta: field.fieldMeta || undefined, + })), + }); + + if (type === EnvelopeType.DOCUMENT) { + await tx.documentAuditLog.createMany({ + data: createdFields.map((createdField) => + createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED, + envelopeId: envelope.id, + metadata: requestMetadata, + data: { + fieldId: createdField.secondaryId, + fieldRecipientEmail: acroFormRecipient.email, + fieldRecipientId: createdField.recipientId, + fieldType: createdField.type, + }, + }), + ), + }); + } + } + } + const createdEnvelope = await tx.envelope.findFirst({ where: { id: envelope.id, diff --git a/packages/lib/server-only/pdf/acroform-fields.test.ts b/packages/lib/server-only/pdf/acroform-fields.test.ts new file mode 100644 index 000000000..231c7b0f5 --- /dev/null +++ b/packages/lib/server-only/pdf/acroform-fields.test.ts @@ -0,0 +1,321 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { PDF } from '@libpdf/core'; +import { FieldType } from '@prisma/client'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { convertAcroFormFieldsToFieldInputs, extractAcroFormFieldsFromPDF } from './acroform-fields'; + +const ASSETS_DIR = path.resolve(__dirname, '../../../../assets'); + +const loadAsset = (name: string) => fs.readFileSync(path.join(ASSETS_DIR, name)); + +const buildPdfBuffer = async (rotation: 0 | 90 | 180 | 270 = 0) => { + const pdf = PDF.create(); + pdf.addPage({ width: 612, height: 792, rotate: rotation }); + pdf.addPage({ width: 612, height: 792, rotate: rotation }); + + const form = pdf.getOrCreateForm(); + const [page1, page2] = pdf.getPages(); + + const customerName = form.createTextField('CustomerName'); + page1.drawField(customerName, { x: 80, y: 620, width: 200, height: 24 }); + + const signedDate = form.createTextField('signed_date'); + page1.drawField(signedDate, { x: 80, y: 560, width: 200, height: 24 }); + + const acceptTerms = form.createCheckbox('accept_terms', { onValue: 'Yes' }); + page1.drawField(acceptTerms, { x: 80, y: 500, width: 18, height: 18 }); + + const country = form.createDropdown('country', { + options: ['USA', 'Canada', 'Germany'], + defaultValue: 'USA', + }); + page2.drawField(country, { x: 80, y: 700, width: 200, height: 24 }); + + const payment = form.createRadioGroup('payment_method', { + options: ['Credit Card', 'PayPal', 'Bank Transfer'], + defaultValue: 'PayPal', + }); + page2.drawField(payment, { x: 80, y: 640, width: 16, height: 16, option: 'Credit Card' }); + page2.drawField(payment, { x: 80, y: 615, width: 16, height: 16, option: 'PayPal' }); + page2.drawField(payment, { x: 80, y: 590, width: 16, height: 16, option: 'Bank Transfer' }); + + const initials = form.createTextField('initials'); + page2.drawField(initials, { x: 80, y: 540, width: 60, height: 24 }); + + const email = form.createTextField('contact_email'); + page2.drawField(email, { x: 160, y: 540, width: 220, height: 24 }); + + const qty = form.createTextField('item_qty', { maxLength: 4 }); + page2.drawField(qty, { x: 400, y: 540, width: 60, height: 24 }); + + return Buffer.from(await pdf.save()); +}; + +describe('extractAcroFormFieldsFromPDF', () => { + let baseBuffer: Buffer; + + beforeAll(async () => { + baseBuffer = await buildPdfBuffer(); + }); + + describe('non-AcroForm input', () => { + it('returns no-form skip reason for PDFs without an AcroForm', async () => { + const pdf = PDF.create(); + pdf.addPage({ width: 612, height: 792 }); + const emptyBuffer = Buffer.from(await pdf.save()); + + const result = await extractAcroFormFieldsFromPDF(emptyBuffer); + + expect(result.fields).toEqual([]); + expect(result.unsupported).toEqual([]); + expect(result.hasSignedSignature).toBe(false); + expect(result.skipReason).toBe('no-form'); + }); + + it('returns error skip reason without throwing for malformed PDFs', async () => { + const garbage = Buffer.from('not a pdf'); + + const result = await extractAcroFormFieldsFromPDF(garbage); + + expect(result.fields).toEqual([]); + expect(result.skipReason).toBe('error'); + }); + }); + + describe('field type resolution', () => { + it('maps each supported AcroForm field type to its Documenso counterpart', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer); + + expect(result.skipReason).toBeUndefined(); + expect(result.hasSignedSignature).toBe(false); + expect(result.unsupported).toEqual([]); + + const byName = new Map(result.fields.map((f) => [f.fieldName, f.fieldAndMeta.type])); + + expect(byName.get('CustomerName')).toBe(FieldType.NAME); + expect(byName.get('signed_date')).toBe(FieldType.DATE); + expect(byName.get('accept_terms')).toBe(FieldType.CHECKBOX); + expect(byName.get('country')).toBe(FieldType.DROPDOWN); + expect(byName.get('payment_method')).toBe(FieldType.RADIO); + expect(byName.get('initials')).toBe(FieldType.INITIALS); + expect(byName.get('contact_email')).toBe(FieldType.EMAIL); + expect(byName.get('item_qty')).toBe(FieldType.NUMBER); + }); + + it('emits one Documenso RADIO field per widget (one field per option)', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer); + const radioFields = result.fields.filter((f) => f.fieldName === 'payment_method'); + + expect(radioFields).toHaveLength(3); + expect(radioFields.every((f) => f.fieldAndMeta.type === FieldType.RADIO)).toBe(true); + }); + + it('stamps source: "acroform" on every imported field meta', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer); + + expect(result.fields.length).toBeGreaterThan(0); + + for (const f of result.fields) { + expect(f.fieldAndMeta.fieldMeta?.source).toBe('acroform'); + } + }); + + it('uses partialName as label when no /TU is set', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer); + const nameField = result.fields.find((f) => f.fieldName === 'CustomerName'); + + expect(nameField).toBeDefined(); + expect(nameField?.fieldAndMeta.fieldMeta?.label).toBe('CustomerName'); + }); + }); + + describe('default values', () => { + it('copies AcroForm dropdown default into fieldMeta.defaultValue when formValues was not provided', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer); + const country = result.fields.find((f) => f.fieldName === 'country'); + + expect(country?.fieldAndMeta.type).toBe(FieldType.DROPDOWN); + if (country?.fieldAndMeta.type === FieldType.DROPDOWN) { + expect(country.fieldAndMeta.fieldMeta?.defaultValue).toBe('USA'); + expect(country.fieldAndMeta.fieldMeta?.values?.map((v) => v.value)).toEqual(['USA', 'Canada', 'Germany']); + } + }); + + it('skips AcroForm defaults when formValuesProvided is true', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer, { formValuesProvided: true }); + const country = result.fields.find((f) => f.fieldName === 'country'); + + expect(country?.fieldAndMeta.type).toBe(FieldType.DROPDOWN); + if (country?.fieldAndMeta.type === FieldType.DROPDOWN) { + expect(country.fieldAndMeta.fieldMeta?.defaultValue).toBe(''); + } + }); + + it('marks the default radio option as checked when formValues was not provided', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer); + const radios = result.fields.filter((f) => f.fieldName === 'payment_method'); + const checkedValues = radios.flatMap((r) => { + if (r.fieldAndMeta.type !== FieldType.RADIO) { + return []; + } + + return (r.fieldAndMeta.fieldMeta?.values ?? []).filter((v) => v.checked).map((v) => v.value); + }); + + expect(checkedValues).toContain('PayPal'); + }); + }); + + describe('geometry', () => { + it('produces percentages relative to rendered page dimensions for non-rotated pages', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer); + const customerName = result.fields.find((f) => f.fieldName === 'CustomerName'); + + expect(customerName).toBeDefined(); + + if (customerName) { + // user (80, 620) with width 200, height 24 on a 612x792 page. + // Rendered top-left: (80, 792 - 620 - 24) = (80, 148). + const xPct = (customerName.x / customerName.pageWidth) * 100; + const yPct = (customerName.y / customerName.pageHeight) * 100; + const wPct = (customerName.width / customerName.pageWidth) * 100; + const hPct = (customerName.height / customerName.pageHeight) * 100; + + expect(xPct).toBeCloseTo((80 / 612) * 100, 2); + expect(yPct).toBeCloseTo((148 / 792) * 100, 2); + expect(wPct).toBeCloseTo((200 / 612) * 100, 2); + expect(hPct).toBeCloseTo((24 / 792) * 100, 2); + } + }); + + it('handles 90-degree rotated pages with the inverse rotation transform', async () => { + const rotated = await buildPdfBuffer(90); + const result = await extractAcroFormFieldsFromPDF(rotated); + const customerName = result.fields.find((f) => f.fieldName === 'CustomerName'); + + expect(customerName).toBeDefined(); + + if (customerName) { + // Rendered page dims for /Rotate 90: width = mediaH (792), height = mediaW (612). + expect(customerName.pageWidth).toBeCloseTo(792, 0); + expect(customerName.pageHeight).toBeCloseTo(612, 0); + + // R=90 transform: renderedX = yB = 620, renderedY = xL = 80. + const xPct = (customerName.x / customerName.pageWidth) * 100; + const yPct = (customerName.y / customerName.pageHeight) * 100; + + expect(xPct).toBeCloseTo((620 / 792) * 100, 2); + expect(yPct).toBeCloseTo((80 / 612) * 100, 2); + } + }); + + it('handles 180-degree rotated pages', async () => { + const rotated = await buildPdfBuffer(180); + const result = await extractAcroFormFieldsFromPDF(rotated); + const customerName = result.fields.find((f) => f.fieldName === 'CustomerName'); + + expect(customerName).toBeDefined(); + + if (customerName) { + // R=180: renderedX = mediaW - xR = 612 - 280 = 332. renderedY = yB = 620. + const xPct = (customerName.x / customerName.pageWidth) * 100; + const yPct = (customerName.y / customerName.pageHeight) * 100; + + expect(xPct).toBeCloseTo((332 / 612) * 100, 2); + expect(yPct).toBeCloseTo((620 / 792) * 100, 2); + } + }); + + it('handles 270-degree rotated pages', async () => { + const rotated = await buildPdfBuffer(270); + const result = await extractAcroFormFieldsFromPDF(rotated); + const customerName = result.fields.find((f) => f.fieldName === 'CustomerName'); + + expect(customerName).toBeDefined(); + + if (customerName) { + // R=270 page dims swap: pageW = mediaH = 792, pageH = mediaW = 612. + // renderedX = mediaH - yT = 792 - 644 = 148. renderedY = mediaW - xR = 612 - 280 = 332. + const xPct = (customerName.x / customerName.pageWidth) * 100; + const yPct = (customerName.y / customerName.pageHeight) * 100; + + expect(xPct).toBeCloseTo((148 / 792) * 100, 2); + expect(yPct).toBeCloseTo((332 / 612) * 100, 2); + } + }); + }); +}); + +describe('convertAcroFormFieldsToFieldInputs', () => { + it('sorts by (page, y, x) before mapping recipients', async () => { + const buffer = await buildPdfBuffer(); + const { fields } = await extractAcroFormFieldsFromPDF(buffer); + + const recipient = { id: 42 }; + const inputs = convertAcroFormFieldsToFieldInputs(fields, () => recipient, 'env_item_1'); + + expect(inputs).toHaveLength(fields.length); + + // Sorted by page first. + const pages = inputs.map((i) => i.page); + const sortedPages = [...pages].sort((a, b) => a - b); + expect(pages).toEqual(sortedPages); + + // Within a single page, positionY (top-left coords) should be ascending + // when rows are >2% apart. + for (let i = 1; i < inputs.length; i++) { + const prev = inputs[i - 1]; + const curr = inputs[i]; + + if (prev.page !== curr.page) { + continue; + } + + const yDelta = curr.positionY - prev.positionY; + // Either next row (yDelta > -2%) or same row (then x must be ascending). + if (Math.abs(yDelta) <= 2) { + expect(curr.positionX).toBeGreaterThanOrEqual(prev.positionX); + } else { + expect(yDelta).toBeGreaterThan(0); + } + } + }); + + it('passes recipientId through the resolver for every imported field', async () => { + const buffer = await buildPdfBuffer(); + const { fields } = await extractAcroFormFieldsFromPDF(buffer); + + const recipient = { id: 7 }; + const inputs = convertAcroFormFieldsToFieldInputs(fields, () => recipient, 'env_item_xyz'); + + for (const input of inputs) { + expect(input.recipientId).toBe(recipient.id); + expect(input.envelopeItemId).toBe('env_item_xyz'); + } + }); +}); + +describe('extractAcroFormFieldsFromPDF — committed fixture', () => { + it('extracts the expected fields from assets/acroform-import-test.pdf', async () => { + const buffer = loadAsset('acroform-import-test.pdf'); + const result = await extractAcroFormFieldsFromPDF(buffer); + + expect(result.skipReason).toBeUndefined(); + expect(result.unsupported).toEqual([]); + expect(result.fields.length).toBeGreaterThanOrEqual(8); + }); + + it('extracts the expected fields from rotated fixtures (90/180/270)', async () => { + for (const angle of [90, 180, 270]) { + const buffer = loadAsset(`acroform-import-rotated-${angle}.pdf`); + const result = await extractAcroFormFieldsFromPDF(buffer); + + expect(result.skipReason).toBeUndefined(); + expect(result.unsupported).toEqual([]); + expect(result.fields.length).toBeGreaterThanOrEqual(8); + } + }); +}); diff --git a/packages/lib/server-only/pdf/acroform-fields.ts b/packages/lib/server-only/pdf/acroform-fields.ts new file mode 100644 index 000000000..0941998a2 --- /dev/null +++ b/packages/lib/server-only/pdf/acroform-fields.ts @@ -0,0 +1,852 @@ +import { PDF, type PDFForm, type PDFPage, type PdfDict, type PdfRef } from '@libpdf/core'; +import { FieldType, type Recipient } from '@prisma/client'; +import { + FIELD_CHECKBOX_META_DEFAULT_VALUES, + FIELD_DATE_META_DEFAULT_VALUES, + FIELD_DROPDOWN_META_DEFAULT_VALUES, + FIELD_EMAIL_META_DEFAULT_VALUES, + FIELD_INITIALS_META_DEFAULT_VALUES, + FIELD_NAME_META_DEFAULT_VALUES, + FIELD_NUMBER_META_DEFAULT_VALUES, + FIELD_RADIO_META_DEFAULT_VALUES, + FIELD_SIGNATURE_META_DEFAULT_VALUES, + FIELD_TEXT_META_DEFAULT_VALUES, + type TCheckboxFieldMeta, + type TDropdownFieldMeta, + type TFieldAndMeta, + type TNumberFieldMeta, + type TRadioFieldMeta, + type TTextFieldMeta, + ZEnvelopeFieldAndMetaSchema, +} from '../../types/field-meta'; +import { logger } from '../../utils/logger'; +import type { FieldToCreate } from './auto-place-fields'; + +/** + * Local shape for the widget annotations returned by @libpdf/core. + * + * The library exposes WidgetAnnotation as a class but does not re-export the + * type from its public surface. We duck-type the subset we actually read. + */ +type WidgetAnnotation = { + readonly rect: [number, number, number, number]; + readonly width: number; + readonly height: number; + readonly pageRef: PdfRef | null; + isHidden(): boolean; + getOnValue(): string | null; +}; + +const DEFAULT_FIELD_HEIGHT_PERCENT = 2; +const MIN_HEIGHT_THRESHOLD = 0.01; + +const DATE_NAME_PATTERN = /date|dob|birth/i; +const NUMBER_NAME_PATTERN = /amount|qty|count|number/i; +const EMAIL_NAME_PATTERN = /email|e[-_]?mail/i; +const NAME_NAME_PATTERN = /name/i; +const INITIALS_NAME_PATTERN = /initial/i; + +const ROW_TOLERANCE_PERCENT = 2; + +export type AcroFormUnsupportedReason = + | 'unsupported-type' + | 'hidden' + | 'off-page' + | 'zero-size' + | 'no-page-match' + | 'signed-signature' + | 'rotated-out-of-bounds'; + +export type AcroFormSkipReason = 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error'; + +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: AcroFormUnsupportedReason; +}; + +export type AcroFormExtractionResult = { + fields: AcroFormFieldImportInfo[]; + unsupported: AcroFormUnsupportedFieldInfo[]; + /** + * True when a signed signature widget was found. + * + * Callers MUST set `flattenForm: false` for that envelope item so the signed + * PDF is not re-flattened (which would invalidate the signature). + */ + hasSignedSignature: boolean; + /** + * Set when extraction returned empty for a reason that should be surfaced in + * logs but not propagated to the user. Absent when extraction ran normally. + */ + skipReason?: AcroFormSkipReason; +}; + +type ResolvedGeometry = { + page: number; + x: number; + y: number; + width: number; + height: number; + pageWidth: number; + pageHeight: number; +}; + +const EMPTY_RESULT = (skipReason?: AcroFormSkipReason): AcroFormExtractionResult => ({ + fields: [], + unsupported: [], + hasSignedSignature: false, + skipReason, +}); + +const hasXfa = (form: PDFForm): boolean => { + // PDFForm doesn't expose the AcroForm dict directly. Inspect a known field's + // parent chain to walk up to the AcroForm dict via /Parent traversal. + const fields = form.getFields(); + if (fields.length === 0) { + return false; + } + + // Walk up to the AcroForm dict by reading the field dict's /Parent chain. + // The root field's parent is null but its containing dict is /AcroForm + // (which holds /XFA). We approximate by checking the first field's raw dict + // for an inherited /XFA reference via getInheritable would require internal + // access; instead, inspect the dict for any /XFA marker keys on the field's + // chain. In practice XFA dicts surface on the AcroForm root, not fields. + // Best-effort: look at PDFForm internals via a duck-typed dict lookup. + type AcroFormInternal = { _acroForm?: { dict?: PdfDict } }; + const internal = form as unknown as AcroFormInternal; + const dict = internal._acroForm?.dict; + + return Boolean(dict?.has('XFA')); +}; + +const isDateFieldByName = (name: string | null | undefined): boolean => { + return name ? DATE_NAME_PATTERN.test(name) : false; +}; + +const isNumberFieldByName = (name: string | null | undefined): boolean => { + return name ? NUMBER_NAME_PATTERN.test(name) : false; +}; + +const isEmailFieldByName = (name: string | null | undefined): boolean => { + return name ? EMAIL_NAME_PATTERN.test(name) : false; +}; + +const isNameFieldByName = (name: string | null | undefined): boolean => { + return name ? NAME_NAME_PATTERN.test(name) : false; +}; + +const isInitialsFieldByName = (name: string | null | undefined): boolean => { + return name ? INITIALS_NAME_PATTERN.test(name) : false; +}; + +/** + * Detect AcroForm format actions on a text field dictionary. + * + * Adobe attaches a JavaScript format action via /AA -> /F -> /JS. The script + * body references `AFDate_FormatEx` or `AFNumber_Format` depending on the + * intended format. We do a string contains check on the raw script to avoid + * pulling in a JS parser. + */ +const getTextFieldFormatHint = (fieldDict: PdfDict): 'date' | 'number' | null => { + try { + const aa = fieldDict.get('AA'); + + if (!aa || typeof aa !== 'object' || aa.type !== 'dict') { + return null; + } + + const aaDict = aa as PdfDict; + const formatEntry = aaDict.get('F'); + + if (!formatEntry || typeof formatEntry !== 'object' || formatEntry.type !== 'dict') { + return null; + } + + const formatDict = formatEntry as PdfDict; + const js = formatDict.get('JS'); + + if (!js) { + return null; + } + + // The JS entry may be a string or a stream. Coerce via toString() — + // both PdfString and PdfStream expose a textual representation. + const script = typeof js === 'string' ? js : String(js); + + if (script.includes('AFDate_FormatEx') || script.includes('AFDate_Format')) { + return 'date'; + } + + if (script.includes('AFNumber_Format')) { + return 'number'; + } + + return null; + } catch { + return null; + } +}; + +type FormFieldWithDict = { + name: string; + partialName: string; + alternateName: string | null; + isReadOnly(): boolean; + isRequired(): boolean; + acroField(): PdfDict; + getWidgets(): WidgetAnnotation[]; +}; + +type ResolvedTextDocumentenoType = + | typeof FieldType.TEXT + | typeof FieldType.DATE + | typeof FieldType.NUMBER + | typeof FieldType.EMAIL + | typeof FieldType.NAME + | typeof FieldType.INITIALS; + +const resolveTextSubtype = ( + field: FormFieldWithDict, +): { + documentenoType: ResolvedTextDocumentenoType; +} => { + const candidateNames = [field.partialName, field.alternateName]; + + let formatHint: 'date' | 'number' | null = null; + + try { + formatHint = getTextFieldFormatHint(field.acroField()); + } catch { + formatHint = null; + } + + if (formatHint === 'date' || candidateNames.some(isDateFieldByName)) { + return { documentenoType: FieldType.DATE }; + } + + let maxLen = Number.POSITIVE_INFINITY; + + try { + const lenEntry = field.acroField().get('MaxLen'); + + if (lenEntry && typeof lenEntry === 'object' && 'value' in lenEntry && typeof lenEntry.value === 'number') { + maxLen = lenEntry.value; + } + } catch { + // Ignore — MaxLen is optional. + } + + if (formatHint === 'number' || (maxLen <= 10 && candidateNames.some(isNumberFieldByName))) { + return { documentenoType: FieldType.NUMBER }; + } + + if (candidateNames.some(isEmailFieldByName)) { + return { documentenoType: FieldType.EMAIL }; + } + + if (candidateNames.some(isNameFieldByName)) { + return { documentenoType: FieldType.NAME }; + } + + if (candidateNames.some(isInitialsFieldByName)) { + return { documentenoType: FieldType.INITIALS }; + } + + return { documentenoType: FieldType.TEXT }; +}; + +const pickLabel = (field: FormFieldWithDict): string | undefined => { + const label = field.alternateName ?? field.partialName; + + return label && label.length > 0 ? label : undefined; +}; + +type RotationDegrees = 0 | 90 | 180 | 270; + +type RawRect = { x1: number; y1: number; x2: number; y2: number }; + +const getRectFromWidget = (widget: WidgetAnnotation): RawRect | null => { + const rect = widget.rect; + + if (!rect || rect.length !== 4) { + return null; + } + + const [x1, y1, x2, y2] = rect; + + if (![x1, y1, x2, y2].every((v) => Number.isFinite(v))) { + return null; + } + + return { x1, y1, x2, y2 }; +}; + +const resolveGeometry = ( + widget: WidgetAnnotation, + pageIndex: number, + page: PDFPage, +): { geometry: ResolvedGeometry | null; reason: AcroFormUnsupportedReason | null } => { + const rect = getRectFromWidget(widget); + + if (!rect) { + return { geometry: null, reason: 'zero-size' }; + } + + const xL = Math.min(rect.x1, rect.x2); + const xR = Math.max(rect.x1, rect.x2); + const yB = Math.min(rect.y1, rect.y2); + const yT = Math.max(rect.y1, rect.y2); + + if (xR - xL <= 0 || yT - yB <= 0) { + return { geometry: null, reason: 'zero-size' }; + } + + const mediaBox = page.getMediaBox(); + const mediaW = mediaBox.width; + const mediaH = mediaBox.height; + const rotation = page.rotation as RotationDegrees; + + // PDFPage.width / .height return rotation-adjusted dimensions, which is what + // we want for percent-based positioning relative to the rendered page. + const renderedW = page.width; + const renderedH = page.height; + + let renderedX: number; + let renderedY: number; + let renderedFieldW: number; + let renderedFieldH: number; + + if (rotation === 90) { + renderedX = yB; + renderedY = xL; + renderedFieldW = yT - yB; + renderedFieldH = xR - xL; + } else if (rotation === 180) { + renderedX = mediaW - xR; + renderedY = yB; + renderedFieldW = xR - xL; + renderedFieldH = yT - yB; + } else if (rotation === 270) { + renderedX = mediaH - yT; + renderedY = mediaW - xR; + renderedFieldW = yT - yB; + renderedFieldH = xR - xL; + } else { + renderedX = xL; + renderedY = mediaH - yT; + renderedFieldW = xR - xL; + renderedFieldH = yT - yB; + } + + // Out-of-bounds: skip if the entire rect is outside the rendered page bounds. + const left = renderedX; + const right = renderedX + renderedFieldW; + const top = renderedY; + const bottom = renderedY + renderedFieldH; + + if (right <= 0 || left >= renderedW || bottom <= 0 || top >= renderedH) { + return { geometry: null, reason: 'off-page' }; + } + + // Partial out-of-bounds: clamp. + const clampedLeft = Math.max(0, Math.min(left, renderedW)); + const clampedRight = Math.max(0, Math.min(right, renderedW)); + const clampedTop = Math.max(0, Math.min(top, renderedH)); + const clampedBottom = Math.max(0, Math.min(bottom, renderedH)); + + const clampedW = clampedRight - clampedLeft; + const clampedH = clampedBottom - clampedTop; + + if (clampedW <= 0 || clampedH <= 0) { + return { geometry: null, reason: 'off-page' }; + } + + return { + geometry: { + page: pageIndex + 1, + x: clampedLeft, + y: clampedTop, + width: clampedW, + height: clampedH, + pageWidth: renderedW, + pageHeight: renderedH, + }, + reason: null, + }; +}; + +const buildSignatureFieldAndMeta = (field: FormFieldWithDict): TFieldAndMeta => { + return ZEnvelopeFieldAndMetaSchema.parse({ + type: FieldType.SIGNATURE, + fieldMeta: { + ...FIELD_SIGNATURE_META_DEFAULT_VALUES, + required: field.isRequired() || undefined, + readOnly: field.isReadOnly() || undefined, + source: 'acroform' as const, + }, + }); +}; + +const buildTextFieldAndMeta = ( + field: FormFieldWithDict, + documentenoType: ResolvedTextDocumentenoType, + defaultText: string | undefined, +): TFieldAndMeta => { + const label = pickLabel(field); + const required = field.isRequired() || undefined; + const readOnly = field.isReadOnly() || undefined; + + if (documentenoType === FieldType.NUMBER) { + const fieldMeta: TNumberFieldMeta = { + ...FIELD_NUMBER_META_DEFAULT_VALUES, + label: label ?? FIELD_NUMBER_META_DEFAULT_VALUES.label, + required, + readOnly, + source: 'acroform' as const, + value: defaultText && defaultText.length > 0 ? defaultText : undefined, + }; + + return ZEnvelopeFieldAndMetaSchema.parse({ type: documentenoType, fieldMeta }); + } + + if (documentenoType === FieldType.TEXT) { + const fieldMeta: TTextFieldMeta = { + ...FIELD_TEXT_META_DEFAULT_VALUES, + label: label ?? FIELD_TEXT_META_DEFAULT_VALUES.label, + required, + readOnly, + source: 'acroform' as const, + text: defaultText && defaultText.length > 0 ? defaultText : FIELD_TEXT_META_DEFAULT_VALUES.text, + }; + + return ZEnvelopeFieldAndMetaSchema.parse({ type: documentenoType, fieldMeta }); + } + + if (documentenoType === FieldType.DATE) { + return ZEnvelopeFieldAndMetaSchema.parse({ + type: documentenoType, + fieldMeta: { + ...FIELD_DATE_META_DEFAULT_VALUES, + label, + required, + readOnly, + source: 'acroform' as const, + }, + }); + } + + if (documentenoType === FieldType.EMAIL) { + return ZEnvelopeFieldAndMetaSchema.parse({ + type: documentenoType, + fieldMeta: { + ...FIELD_EMAIL_META_DEFAULT_VALUES, + label, + required, + readOnly, + source: 'acroform' as const, + }, + }); + } + + if (documentenoType === FieldType.NAME) { + return ZEnvelopeFieldAndMetaSchema.parse({ + type: documentenoType, + fieldMeta: { + ...FIELD_NAME_META_DEFAULT_VALUES, + label, + required, + readOnly, + source: 'acroform' as const, + }, + }); + } + + return ZEnvelopeFieldAndMetaSchema.parse({ + type: documentenoType, + fieldMeta: { + ...FIELD_INITIALS_META_DEFAULT_VALUES, + label, + required, + readOnly, + source: 'acroform' as const, + }, + }); +}; + +const buildCheckboxFieldAndMeta = ( + field: FormFieldWithDict, + onValue: string | undefined, + isChecked: boolean, +): TFieldAndMeta => { + const required = field.isRequired(); + const value = onValue && onValue.length > 0 ? onValue : 'Yes'; + + const fieldMeta: TCheckboxFieldMeta = { + ...FIELD_CHECKBOX_META_DEFAULT_VALUES, + label: pickLabel(field) ?? FIELD_CHECKBOX_META_DEFAULT_VALUES.label, + required: required || undefined, + readOnly: field.isReadOnly() || undefined, + source: 'acroform' as const, + values: [{ id: 1, checked: isChecked, value }], + validationRule: required ? 'at-least' : '', + validationLength: required ? 1 : 0, + }; + + return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.CHECKBOX, fieldMeta }); +}; + +const buildRadioFieldAndMeta = ( + field: FormFieldWithDict, + options: string[], + selectedValue: string | null, + widgetOnValue: string | null, +): TFieldAndMeta => { + const values = + options.length > 0 + ? options.map((value, index) => ({ + id: index + 1, + checked: selectedValue !== null && value === selectedValue, + value, + })) + : [ + { + id: 1, + checked: widgetOnValue !== null && widgetOnValue === selectedValue, + value: widgetOnValue ?? '', + }, + ]; + + const fieldMeta: TRadioFieldMeta = { + ...FIELD_RADIO_META_DEFAULT_VALUES, + label: pickLabel(field) ?? '', + required: field.isRequired() || undefined, + readOnly: field.isReadOnly() || undefined, + source: 'acroform' as const, + values, + }; + + return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.RADIO, fieldMeta }); +}; + +const buildDropdownFieldAndMeta = ( + field: FormFieldWithDict, + options: string[], + defaultValue: string | undefined, +): TFieldAndMeta => { + const fieldMeta: TDropdownFieldMeta = { + ...FIELD_DROPDOWN_META_DEFAULT_VALUES, + label: pickLabel(field) ?? '', + required: field.isRequired() || undefined, + readOnly: field.isReadOnly() || undefined, + source: 'acroform' as const, + values: options.length > 0 ? options.map((value) => ({ value })) : FIELD_DROPDOWN_META_DEFAULT_VALUES.values, + defaultValue: defaultValue ?? '', + }; + + return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.DROPDOWN, fieldMeta }); +}; + +type WidgetWithPage = { widget: WidgetAnnotation; pageIndex: number; page: PDFPage }; + +const resolveWidgetPages = ( + widgets: WidgetAnnotation[], + pageByRef: Map, +): { + matched: WidgetWithPage[]; + unmatched: WidgetAnnotation[]; +} => { + const matched: WidgetWithPage[] = []; + const unmatched: WidgetAnnotation[] = []; + + for (const widget of widgets) { + const pageRef = widget.pageRef; + const resolved = pageRef ? pageByRef.get(pageRef) : null; + + if (!resolved) { + unmatched.push(widget); + continue; + } + + matched.push({ widget, pageIndex: resolved.index, page: resolved.page }); + } + + return { matched, unmatched }; +}; + +/** + * Options for {@link extractAcroFormFieldsFromPDF}. + */ +export type ExtractAcroFormOptions = { + /** + * When true, `insertFormValuesInPdf` already ran for this buffer. The + * extractor will not copy AcroForm default values into `fieldMeta` to + * avoid duplicating values that are already baked into the flattened PDF. + */ + formValuesProvided?: boolean; +}; + +/** + * Extract AcroForm fields from a PDF and convert them to Documenso field + * imports. + * + * Runs before flattening so widget geometry is still present in the buffer. + * Returns an empty result for non-AcroForm PDFs, encrypted PDFs, XFA hybrids, + * and on any internal error (with `skipReason` set so callers can log). + */ +export const extractAcroFormFieldsFromPDF = async ( + pdf: Buffer, + options: ExtractAcroFormOptions = {}, +): Promise => { + try { + const pdfDoc = await PDF.load(new Uint8Array(pdf)); + + if (pdfDoc.isEncrypted) { + return EMPTY_RESULT('encrypted'); + } + + const form = pdfDoc.getForm(); + + if (!form) { + return EMPTY_RESULT('no-form'); + } + + if (hasXfa(form)) { + return EMPTY_RESULT('xfa-hybrid'); + } + + const pages = pdfDoc.getPages(); + const pageByRef = new Map(); + + pages.forEach((page, index) => { + pageByRef.set(page.ref, { index, page }); + }); + + const fields: AcroFormFieldImportInfo[] = []; + const unsupported: AcroFormUnsupportedFieldInfo[] = []; + let hasSignedSignature = false; + + for (const field of form.getFields()) { + const acroFormType = field.type; + + if ( + acroFormType === 'listbox' || + acroFormType === 'button' || + acroFormType === 'unknown' || + acroFormType === 'non-terminal' + ) { + unsupported.push({ + fieldName: field.name, + acroFormType, + reason: 'unsupported-type', + }); + continue; + } + + // Signed signature widgets are skipped entirely and the caller is asked + // to keep the form intact (no flatten) so the signature stays valid. + if (acroFormType === 'signature') { + type SignatureFieldDuck = FormFieldWithDict & { isSigned(): boolean }; + const sigField = field as unknown as SignatureFieldDuck; + + if (typeof sigField.isSigned === 'function' && sigField.isSigned()) { + hasSignedSignature = true; + unsupported.push({ + fieldName: field.name, + acroFormType, + reason: 'signed-signature', + }); + continue; + } + } + + const formField = field as unknown as FormFieldWithDict; + const widgets = formField.getWidgets(); + const { matched, unmatched } = resolveWidgetPages(widgets, pageByRef); + + for (const widget of unmatched) { + unsupported.push({ + fieldName: field.name, + acroFormType, + reason: 'no-page-match', + }); + // Reference widget so it isn't tree-shaken in dev tooling; harmless at runtime. + void widget; + } + + let widgetCounter = 0; + + for (const { widget, pageIndex, page } of matched) { + if (widget.isHidden()) { + unsupported.push({ + fieldName: field.name, + acroFormType, + reason: 'hidden', + }); + continue; + } + + const { geometry, reason } = resolveGeometry(widget, pageIndex, page); + + if (!geometry) { + unsupported.push({ + fieldName: field.name, + acroFormType, + reason: reason ?? 'zero-size', + }); + continue; + } + + let fieldAndMeta: TFieldAndMeta; + const usePdfDefaults = !options.formValuesProvided; + + if (acroFormType === 'signature') { + fieldAndMeta = buildSignatureFieldAndMeta(formField); + } else if (acroFormType === 'text') { + type TextFieldDuck = FormFieldWithDict & { + getValue(): string; + getDefaultValue(): string; + }; + const textField = field as unknown as TextFieldDuck; + const { documentenoType } = resolveTextSubtype(formField); + const defaultText = usePdfDefaults ? textField.getValue?.() || textField.getDefaultValue?.() || '' : ''; + fieldAndMeta = buildTextFieldAndMeta(formField, documentenoType, defaultText); + } else if (acroFormType === 'checkbox') { + type CheckboxFieldDuck = FormFieldWithDict & { + isChecked(): boolean; + getOnValue(): string; + }; + const checkbox = field as unknown as CheckboxFieldDuck; + const onValue = widget.getOnValue() ?? checkbox.getOnValue?.(); + const checked = usePdfDefaults ? (checkbox.isChecked?.() ?? false) : false; + fieldAndMeta = buildCheckboxFieldAndMeta(formField, onValue ?? undefined, checked); + } else if (acroFormType === 'radio') { + type RadioFieldDuck = FormFieldWithDict & { + getOptions(): string[]; + getValue(): string | null; + }; + const radio = field as unknown as RadioFieldDuck; + const selectedValue = usePdfDefaults ? (radio.getValue?.() ?? null) : null; + fieldAndMeta = buildRadioFieldAndMeta( + formField, + radio.getOptions?.() ?? [], + selectedValue, + widget.getOnValue(), + ); + } else if (acroFormType === 'dropdown') { + type DropdownFieldDuck = FormFieldWithDict & { + getOptions(): Array<{ value: string; display: string }>; + getValue(): string; + getDefaultValue(): string; + }; + const dropdown = field as unknown as DropdownFieldDuck; + const rawOptions = dropdown.getOptions?.() ?? []; + const optionValues = rawOptions.map((opt) => opt.value); + const currentSelection = usePdfDefaults ? dropdown.getValue?.() || dropdown.getDefaultValue?.() || '' : ''; + fieldAndMeta = buildDropdownFieldAndMeta(formField, optionValues, currentSelection || undefined); + } else { + unsupported.push({ + fieldName: field.name, + acroFormType, + reason: 'unsupported-type', + }); + continue; + } + + fields.push({ + source: 'acroform', + fieldName: field.name, + widgetIndex: widgetCounter, + fieldAndMeta, + page: geometry.page, + x: geometry.x, + y: geometry.y, + width: geometry.width, + height: geometry.height, + pageWidth: geometry.pageWidth, + pageHeight: geometry.pageHeight, + }); + + widgetCounter += 1; + } + } + + return { + fields, + unsupported, + hasSignedSignature, + }; + } catch (err) { + logger.error({ event: 'acroform-import.error', err }, 'AcroForm extraction threw'); + + return EMPTY_RESULT('error'); + } +}; + +const sortFieldsForCreate = (fields: AcroFormFieldImportInfo[]): AcroFormFieldImportInfo[] => { + return [...fields].sort((a, b) => { + if (a.page !== b.page) { + return a.page - b.page; + } + + const aRowPercent = (a.y / a.pageHeight) * 100; + const bRowPercent = (b.y / b.pageHeight) * 100; + + if (Math.abs(aRowPercent - bRowPercent) > ROW_TOLERANCE_PERCENT) { + return aRowPercent - bRowPercent; + } + + return a.x - b.x; + }); +}; + +/** + * Convert pre-extracted AcroForm fields to field creation inputs. + * + * Pure data transform — converts points to percentages and resolves the + * recipient via the provided callback. No DB calls. + */ +export const convertAcroFormFieldsToFieldInputs = ( + fields: AcroFormFieldImportInfo[], + recipientResolver: (fieldName: string) => Pick, + envelopeItemId?: string, +): FieldToCreate[] => { + const sorted = sortFieldsForCreate(fields); + + return sorted.map((f) => { + const xPercent = (f.x / f.pageWidth) * 100; + const yPercent = (f.y / f.pageHeight) * 100; + const widthPercent = (f.width / f.pageWidth) * 100; + const heightPercent = (f.height / f.pageHeight) * 100; + + const finalHeightPercent = heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT; + + const recipient = recipientResolver(f.fieldName); + + return { + ...f.fieldAndMeta, + envelopeItemId, + recipientId: recipient.id, + page: f.page, + positionX: xPercent, + positionY: yPercent, + width: widthPercent, + height: finalHeightPercent, + }; + }); +}; diff --git a/packages/lib/types/field-meta.ts b/packages/lib/types/field-meta.ts index bb65bf782..6f09ed3cc 100644 --- a/packages/lib/types/field-meta.ts +++ b/packages/lib/types/field-meta.ts @@ -72,6 +72,7 @@ export const ZBaseFieldMeta = z.object({ readOnly: z.boolean().optional(), fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(), overflow: ZFieldOverflowMode.optional(), + source: z.enum(['acroform']).optional(), }); export type TBaseFieldMeta = z.infer; diff --git a/packages/trpc/server/envelope-router/create-envelope.ts b/packages/trpc/server/envelope-router/create-envelope.ts index c721ad31e..ea1d3a947 100644 --- a/packages/trpc/server/envelope-router/create-envelope.ts +++ b/packages/trpc/server/envelope-router/create-envelope.ts @@ -2,6 +2,7 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { convertToPdf } from '@documenso/lib/server-only/document-conversion'; import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope'; +import { extractAcroFormFieldsFromPDF } from '@documenso/lib/server-only/pdf/acroform-fields'; import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields'; import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf'; import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; @@ -106,7 +107,10 @@ export const createEnvelopeRouteCaller = async ({ }); } - // For each file: convert to PDF if needed, normalize, extract & clean placeholders, then upload. + // For each file: convert to PDF if needed, extract AcroForm widgets, + // normalize (which flattens the form unless we detected a signed signature + // and unless this is a template upload), extract & clean placeholders, + // then upload. const envelopeItems = await Promise.all( files.map(async (file) => { let pdf = await convertToPdf(file, logger); @@ -119,8 +123,55 @@ export const createEnvelopeRouteCaller = async ({ }); } + // Run AcroForm extraction BEFORE normalizePdf — flattening destroys + // widget geometry, which we need to reuse as Documenso fields. + const acroFormExtraction = await extractAcroFormFieldsFromPDF(pdf, { + formValuesProvided: Boolean(formValues), + }); + + if (acroFormExtraction.skipReason) { + logger?.info( + { + event: 'acroform-import.skip', + envelopeItemTitle: file.name, + reason: acroFormExtraction.skipReason, + }, + 'AcroForm extraction skipped', + ); + } + + if (acroFormExtraction.unsupported.length > 0) { + const byReason: Record = {}; + + for (const entry of acroFormExtraction.unsupported) { + byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1; + } + + logger?.info( + { + event: 'acroform-import.unsupported', + envelopeItemTitle: file.name, + count: acroFormExtraction.unsupported.length, + byReason, + }, + 'AcroForm import skipped unsupported widgets', + ); + } + + if (acroFormExtraction.hasSignedSignature) { + logger?.warn( + { + event: 'acroform-import.signed-pdf-no-flatten', + envelopeItemTitle: file.name, + }, + 'Signed AcroForm signature detected — skipping flatten to preserve signature', + ); + } + + const shouldFlatten = type !== EnvelopeType.TEMPLATE && !acroFormExtraction.hasSignedSignature; + const normalized = await normalizePdf(pdf, { - flattenForm: type !== EnvelopeType.TEMPLATE, + flattenForm: shouldFlatten, }); // Todo: Embeds - Might need to add this for client-side embeds in the future. @@ -136,6 +187,7 @@ export const createEnvelopeRouteCaller = async ({ title: file.name, documentDataId: documentData.id, placeholders, + acroFormFields: acroFormExtraction.fields, }; }), ); diff --git a/scripts/generate-acroform-test-pdf.mjs b/scripts/generate-acroform-test-pdf.mjs new file mode 100644 index 000000000..84d7080a4 --- /dev/null +++ b/scripts/generate-acroform-test-pdf.mjs @@ -0,0 +1,99 @@ +// scripts/generate-acroform-test-pdf.mjs +// +// Generates the AcroForm import fixture PDFs used by the test suite. +// Run via: node scripts/generate-acroform-test-pdf.mjs + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { PDF } from '@libpdf/core'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ASSETS_DIR = path.resolve(__dirname, '..', 'assets'); + +const LETTER_WIDTH = 612; +const LETTER_HEIGHT = 792; + +/** + * Build the base fixture: one of each supported AcroForm field type with a + * mix of name heuristics so the type-resolution heuristic gets exercised. + */ +async function buildBaseFixture({ rotation = 0 } = {}) { + const pdf = PDF.create(); + + pdf.addPage({ width: LETTER_WIDTH, height: LETTER_HEIGHT, rotate: rotation }); + pdf.addPage({ width: LETTER_WIDTH, height: LETTER_HEIGHT, rotate: rotation }); + + const page1 = pdf.getPage(0); + const page2 = pdf.getPage(1); + + const form = pdf.getOrCreateForm(); + + // Page 1: text (NAME heuristic), text (DATE heuristic), checkbox. + // Note: SignatureField widgets can't be drawn without going through libpdf's + // PDFSignature path. Production PDFs (Adobe Acrobat etc.) supply the widget + // themselves; covered via unit-test mocks rather than this generator. + + const customerName = form.createTextField('CustomerName'); + page1.drawField(customerName, { x: 80, y: 620, width: 200, height: 24 }); + + const signedDate = form.createTextField('signed_date'); + page1.drawField(signedDate, { x: 80, y: 560, width: 200, height: 24 }); + + const acceptTerms = form.createCheckbox('accept_terms', { onValue: 'Yes' }); + page1.drawField(acceptTerms, { x: 80, y: 500, width: 18, height: 18 }); + + // Page 2: dropdown, radio (3 options), text (INITIALS), text (EMAIL), + // text (NUMBER via name + small MaxLen), and a required-readonly text. + const country = form.createDropdown('country', { + options: ['USA', 'Canada', 'Germany'], + defaultValue: 'USA', + }); + page2.drawField(country, { x: 80, y: 700, width: 200, height: 24 }); + + const payment = form.createRadioGroup('payment_method', { + options: ['Credit Card', 'PayPal', 'Bank Transfer'], + defaultValue: 'PayPal', + }); + page2.drawField(payment, { x: 80, y: 640, width: 16, height: 16, option: 'Credit Card' }); + page2.drawField(payment, { x: 80, y: 615, width: 16, height: 16, option: 'PayPal' }); + page2.drawField(payment, { x: 80, y: 590, width: 16, height: 16, option: 'Bank Transfer' }); + + const initials = form.createTextField('initials'); + page2.drawField(initials, { x: 80, y: 540, width: 60, height: 24 }); + + const email = form.createTextField('contact_email'); + page2.drawField(email, { x: 160, y: 540, width: 220, height: 24 }); + + const qty = form.createTextField('item_qty', { maxLength: 4 }); + page2.drawField(qty, { x: 400, y: 540, width: 60, height: 24 }); + + return Buffer.from(await pdf.save()); +} + +function ensureAssetsDir() { + if (!fs.existsSync(ASSETS_DIR)) { + fs.mkdirSync(ASSETS_DIR, { recursive: true }); + } +} + +async function main() { + ensureAssetsDir(); + + const base = await buildBaseFixture(); + fs.writeFileSync(path.join(ASSETS_DIR, 'acroform-import-test.pdf'), base); + + const rot90 = await buildBaseFixture({ rotation: 90 }); + fs.writeFileSync(path.join(ASSETS_DIR, 'acroform-import-rotated-90.pdf'), rot90); + + const rot180 = await buildBaseFixture({ rotation: 180 }); + fs.writeFileSync(path.join(ASSETS_DIR, 'acroform-import-rotated-180.pdf'), rot180); + + const rot270 = await buildBaseFixture({ rotation: 270 }); + fs.writeFileSync(path.join(ASSETS_DIR, 'acroform-import-rotated-270.pdf'), rot270); + + console.log('Wrote fixtures to', ASSETS_DIR); +} + +await main();