From b8a11df768903ea5cfa9f075331dc7e8d25da7f2 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Thu, 21 May 2026 04:05:12 +0000 Subject: [PATCH] feat(pdf): import AcroForm widgets as Documenso fields on upload Detect AcroForm widgets (text, checkbox, radio, dropdown, signature) at upload time and reuse their geometry as Documenso fields instead of stripping them via form.flatten(). Imported fields land in the editor as ordinary Field rows assigned to the first signable recipient, removing the manual re-placement step users hit when preparing PDFs in Adobe Acrobat. Extraction runs before normalizePdf so widget geometry is still readable. Text fields go through a name+format heuristic that maps DATE/NUMBER/EMAIL/ NAME/INITIALS/TEXT, with AcroForm /AA format actions taking precedence over name tokens. Coordinates are converted via per-rotation transforms (0/90/180/ 270) against the rendered page dimensions; widgets fully off-page are dropped, partial overlap is clamped. Signed signatures (SignatureField. isSigned()) are detected and skip both the import and the form flatten so the signature stays valid. Encrypted PDFs, XFA hybrids, malformed PDFs, and internal extractor errors all return an empty result with skipReason set so the upload proceeds untouched. Every imported field carries fieldMeta.source = 'acroform' (new optional on ZBaseFieldMeta) for future provenance queries. DOCUMENT envelopes emit a per-field FIELD_CREATED audit entry matching create-envelope-fields.ts. Recipient assignment picks the first Recipient with role SIGNER or APPROVER sorted by (signingOrder asc nulls last, id asc); when no signable recipient exists, a placeholder Recipient 1 SIGNER is created mirroring the placeholder-pipeline behaviour. --- ...leaf-acroform-field-detection-and-reuse.md | 424 +++++++++ assets/acroform-import-rotated-180.pdf | 717 +++++++++++++++ assets/acroform-import-rotated-270.pdf | 717 +++++++++++++++ assets/acroform-import-rotated-90.pdf | 717 +++++++++++++++ assets/acroform-import-test.pdf | 715 +++++++++++++++ .../e2e/scenarios/acroform-import.spec.ts | 130 +++ .../envelope-item/create-envelope-items.ts | 128 ++- .../server-only/envelope/create-envelope.ts | 133 +++ .../server-only/pdf/acroform-fields.test.ts | 321 +++++++ .../lib/server-only/pdf/acroform-fields.ts | 852 ++++++++++++++++++ packages/lib/types/field-meta.ts | 1 + .../server/envelope-router/create-envelope.ts | 56 +- scripts/generate-acroform-test-pdf.mjs | 99 ++ 13 files changed, 5006 insertions(+), 4 deletions(-) create mode 100644 .agents/plans/loud-orange-leaf-acroform-field-detection-and-reuse.md create mode 100644 assets/acroform-import-rotated-180.pdf create mode 100644 assets/acroform-import-rotated-270.pdf create mode 100644 assets/acroform-import-rotated-90.pdf create mode 100644 assets/acroform-import-test.pdf create mode 100644 packages/app-tests/e2e/scenarios/acroform-import.spec.ts create mode 100644 packages/lib/server-only/pdf/acroform-fields.test.ts create mode 100644 packages/lib/server-only/pdf/acroform-fields.ts create mode 100644 scripts/generate-acroform-test-pdf.mjs 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();