mirror of
https://github.com/documenso/documenso.git
synced 2026-08-19 13:01:50 +10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2662329455 | ||
|
|
26f8a56248 | ||
|
|
fc4de113de | ||
|
|
5e11db2444 | ||
|
|
88e836ddbc | ||
|
|
874243700f | ||
|
|
824117d47e | ||
|
|
d33714a4e5 | ||
|
|
b620a8c6d7 | ||
|
|
b8a11df768 |
@@ -0,0 +1,430 @@
|
|||||||
|
---
|
||||||
|
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 — `SignatureField.isSigned()` returns true) | — | **Skip.** Log `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`. Also downgrade `flattenForm` to `false` for that envelope item (do not re-flatten a signed PDF). |
|
||||||
|
| `text` | resolved by heuristic below | Heuristic order: AcroForm format action → name token → default to TEXT. |
|
||||||
|
| `checkbox` | `CHECKBOX` | One Documenso field per widget. |
|
||||||
|
| `radio` | `RADIO` | One Documenso field per widget. Store group's `getOptions()` in each `fieldMeta.values`. Semantics intentionally differ from PDF (each is independent); documented under Risks. |
|
||||||
|
| `dropdown` | `DROPDOWN` | Preserve `getOptions()` in `fieldMeta.values`, current selection in `fieldMeta.defaultValue`. |
|
||||||
|
| `listbox`, `button`, `unknown`, `non-terminal` | — | Skip, return as `AcroFormUnsupportedFieldInfo`. Never block upload. |
|
||||||
|
|
||||||
|
### Text-field heuristic (resolved in order — first match wins)
|
||||||
|
|
||||||
|
AcroForm format actions take precedence over every name token. If `/AA → /F → /JS` references a known formatter, that result is final. Only when no format action is detected do the name regexes apply.
|
||||||
|
|
||||||
|
1. **DATE** if `acroField()` carries an additional-actions date format (`/AA` → `/F` → `JS` containing `AFDate_FormatEx` or `AFDate_Format`).
|
||||||
|
2. **NUMBER** if `acroField()` carries an `AFNumber_Format` action.
|
||||||
|
3. **DATE** if name/alternateName matches `/date|dob|birth/i`.
|
||||||
|
4. **NUMBER** if name/alternateName matches `/amount|qty|count|number/i` AND `/MaxLen <= 10`.
|
||||||
|
5. **EMAIL** if name/alternateName matches `/email|e[-_]?mail/i`.
|
||||||
|
6. **NAME** if name/alternateName matches `/name/i`.
|
||||||
|
7. **INITIALS** if name/alternateName matches `/initial/i`.
|
||||||
|
8. Else **TEXT**.
|
||||||
|
|
||||||
|
All regexes are case-insensitive and run against `partialName` then `alternateName`.
|
||||||
|
|
||||||
|
Patterns are intentionally lenient to handle CamelCase Adobe Acrobat names (e.g. `CustomerName`, `BirthDate`) that strict word-boundary patterns miss. Expected false positives — `username` → NAME, `birth_name` → DATE, `initialize` → INITIALS — are tolerable because the editor is the final arbiter; false negatives fall through to TEXT (always safe).
|
||||||
|
|
||||||
|
### Metadata mapping
|
||||||
|
|
||||||
|
For every imported field:
|
||||||
|
|
||||||
|
- `fieldMeta.required = field.isRequired()` (boolean; omit when false).
|
||||||
|
- `fieldMeta.readOnly = field.isReadOnly()` (boolean; omit when false). Read-only fields **are** imported (rendered for reference in the editor); the renderer already 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 = bottom`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
|
||||||
|
- `rot === 270`: `x = pageH - top`, `y = pageW - right`, `w = top - bottom`, `h = right - left`. Page dims swap.
|
||||||
|
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<AcroFormExtractionResult>;
|
||||||
|
|
||||||
|
export const convertAcroFormFieldsToFieldInputs = (
|
||||||
|
fields: AcroFormFieldImportInfo[],
|
||||||
|
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
|
||||||
|
envelopeItemId?: string,
|
||||||
|
): FieldToCreate[];
|
||||||
|
```
|
||||||
|
|
||||||
|
`extractAcroFormFieldsFromPDF`:
|
||||||
|
|
||||||
|
- Wraps everything in try/catch (top-level guard).
|
||||||
|
- Loads via `PDF.load(new Uint8Array(pdf))`.
|
||||||
|
- Runs pre-extraction guards (encrypted, XFA, null form) — returns early with `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 (`SignatureField.isSigned()` returns true) → `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).
|
||||||
@@ -20,18 +20,20 @@ import {
|
|||||||
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
|
||||||
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
|
import { getOverlappingFieldPairs } from '@documenso/lib/utils/fields-overlap';
|
||||||
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
import type { MessageDescriptor } from '@lingui/core';
|
import type { MessageDescriptor } from '@lingui/core';
|
||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { AlertTriangleIcon, FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
import { AlertTriangleIcon, FileTextIcon, FormInputIcon, PencilIcon, SparklesIcon } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useRevalidator, useSearchParams } from 'react-router';
|
import { useRevalidator, useSearchParams } from 'react-router';
|
||||||
import { isDeepEqual } from 'remeda';
|
import { isDeepEqual } from 'remeda';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@@ -78,7 +80,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
|
|
||||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields, navigateToStep, editorConfig, flushAutosave, syncEnvelope } =
|
||||||
|
useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
@@ -86,7 +89,32 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
|
|
||||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||||
|
const [acroFormHasFieldsByItemRevision, setAcroFormHasFieldsByItemRevision] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const { mutateAsync: importFieldsFromPdf, isPending: isImportingFieldsFromPdf } =
|
||||||
|
trpc.envelope.field.importFromPdf.useMutation();
|
||||||
|
|
||||||
|
const currentEnvelopeItemRevision = currentEnvelopeItem
|
||||||
|
? `${currentEnvelopeItem.id}:${currentEnvelopeItem.documentDataId}`
|
||||||
|
: null;
|
||||||
|
const currentItemHasAcroForm =
|
||||||
|
currentEnvelopeItemRevision !== null && acroFormHasFieldsByItemRevision[currentEnvelopeItemRevision] === true;
|
||||||
|
|
||||||
|
const onAcroFormDetected = useCallback(
|
||||||
|
(hasFields: boolean) => {
|
||||||
|
if (!currentEnvelopeItemRevision) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAcroFormHasFieldsByItemRevision((prev) =>
|
||||||
|
prev[currentEnvelopeItemRevision] === hasFields ? prev : { ...prev, [currentEnvelopeItemRevision]: hasFields },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[currentEnvelopeItemRevision],
|
||||||
|
);
|
||||||
|
|
||||||
const envelopeItemPermissions = useMemo(
|
const envelopeItemPermissions = useMemo(
|
||||||
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
||||||
@@ -201,6 +229,40 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onImportFromPdfClick = async () => {
|
||||||
|
try {
|
||||||
|
await flushAutosave();
|
||||||
|
const result = await importFieldsFromPdf({ envelopeId: envelope.id });
|
||||||
|
|
||||||
|
if (result.fieldsCreated === 0) {
|
||||||
|
toast({
|
||||||
|
title: _(msg`No form fields found`),
|
||||||
|
description: _(msg`This PDF does not contain any importable form fields.`),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncEnvelope();
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: _(msg`Fields imported`),
|
||||||
|
description: _(
|
||||||
|
msg`Imported ${result.fieldsCreated} field${result.fieldsCreated === 1 ? '' : 's'} from the PDF form. Review and reassign in the editor.`,
|
||||||
|
),
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast({
|
||||||
|
title: _(msg`Could not import fields`),
|
||||||
|
description: _(msg`Something went wrong while importing fields from the PDF.`),
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full">
|
<div className="relative flex h-full">
|
||||||
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
|
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
|
||||||
@@ -288,6 +350,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||||
scrollParentRef={scrollableContainerRef}
|
scrollParentRef={scrollableContainerRef}
|
||||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||||
|
onAcroFormDetected={onAcroFormDetected}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
@@ -380,6 +443,20 @@ export const EnvelopeEditorFieldsPage = () => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentItemHasAcroForm && envelope.status === DocumentStatus.DRAFT && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4 w-full"
|
||||||
|
onClick={() => void onImportFromPdfClick()}
|
||||||
|
disabled={isImportingFieldsFromPdf}
|
||||||
|
>
|
||||||
|
<FormInputIcon className="mr-2 -ml-1 h-4 w-4" />
|
||||||
|
{isImportingFieldsFromPdf ? <Trans>Importing...</Trans> : <Trans>Import from PDF form</Trans>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Field details section. */}
|
{/* Field details section. */}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PDFViewerLazy
|
<PDFViewerLazy
|
||||||
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
|
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}-${currentEnvelopeItem.documentDataId}`}
|
||||||
{...props}
|
{...props}
|
||||||
className={cn('h-full w-full max-w-[800px]', className)}
|
className={cn('h-full w-full max-w-[800px]', className)}
|
||||||
data={currentEnvelopeItem.data}
|
data={currentEnvelopeItem.data}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export type PDFViewerProps = {
|
|||||||
scrollParentRef: ScrollTarget;
|
scrollParentRef: ScrollTarget;
|
||||||
|
|
||||||
onDocumentLoad?: () => void;
|
onDocumentLoad?: () => void;
|
||||||
|
onAcroFormDetected?: (hasFields: boolean) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Additional component to render next to the image, such as a Konva canvas
|
* Additional component to render next to the image, such as a Konva canvas
|
||||||
@@ -63,6 +64,7 @@ export default function PDFViewer({
|
|||||||
data,
|
data,
|
||||||
scrollParentRef,
|
scrollParentRef,
|
||||||
onDocumentLoad,
|
onDocumentLoad,
|
||||||
|
onAcroFormDetected,
|
||||||
customPageRenderer,
|
customPageRenderer,
|
||||||
...props
|
...props
|
||||||
}: PDFViewerProps) {
|
}: PDFViewerProps) {
|
||||||
@@ -124,6 +126,20 @@ export default function PDFViewer({
|
|||||||
// eslint-disable-next-line require-atomic-updates
|
// eslint-disable-next-line require-atomic-updates
|
||||||
pdfRef.current = loadedPdf;
|
pdfRef.current = loadedPdf;
|
||||||
|
|
||||||
|
if (onAcroFormDetected) {
|
||||||
|
try {
|
||||||
|
const fieldObjects = await loadedPdf.getFieldObjects();
|
||||||
|
|
||||||
|
if (!isCancelled) {
|
||||||
|
onAcroFormDetected(fieldObjects !== null && Object.keys(fieldObjects).length > 0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!isCancelled) {
|
||||||
|
onAcroFormDetected(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch the pages
|
// Fetch the pages
|
||||||
const pages = await pMap(Array.from({ length: loadedPdf.numPages }), async (_, pageIndex) => {
|
const pages = await pMap(Array.from({ length: loadedPdf.numPages }), async (_, pageIndex) => {
|
||||||
const page = await loadedPdf.getPage(pageIndex + 1);
|
const page = await loadedPdf.getPage(pageIndex + 1);
|
||||||
@@ -168,7 +184,7 @@ export default function PDFViewer({
|
|||||||
pdfRef.current = null;
|
pdfRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [data]);
|
}, [data, onAcroFormDetected]);
|
||||||
|
|
||||||
// Notify when document is loaded
|
// Notify when document is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
|
||||||
|
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
|
||||||
|
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||||
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||||
|
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 { PDF, PdfString } from '@libpdf/core';
|
||||||
|
import { type APIRequestContext, expect, type Page, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
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'));
|
||||||
|
|
||||||
|
const ACROFORM_DOCUMENT_PAYLOAD: TCreateEnvelopePayload = {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
title: 'AcroForm document',
|
||||||
|
recipients: [
|
||||||
|
{
|
||||||
|
email: 'signer@example.com',
|
||||||
|
name: 'Signer',
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_REQUEST_METADATA: ApiRequestMetadata = {
|
||||||
|
requestMetadata: {},
|
||||||
|
source: 'apiV1',
|
||||||
|
auth: 'api',
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestUser = Awaited<ReturnType<typeof seedUser>>['user'];
|
||||||
|
type TestTeam = Awaited<ReturnType<typeof seedUser>>['team'];
|
||||||
|
|
||||||
|
const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser; team: TestTeam }> => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
const { token } = await createApiToken({
|
||||||
|
userId: user.id,
|
||||||
|
teamId: team.id,
|
||||||
|
tokenName: 'test',
|
||||||
|
expiresIn: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token, user, team };
|
||||||
|
};
|
||||||
|
|
||||||
|
const pdfHasFormFields = async (pdf: Uint8Array): Promise<boolean> => {
|
||||||
|
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||||
|
const form = pdfDoc.getForm();
|
||||||
|
|
||||||
|
return (form?.fieldCount ?? 0) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSignedSignatureAcroFormPdf = (): Promise<Uint8Array> => {
|
||||||
|
const pdf = PDF.create();
|
||||||
|
const page = pdf.addPage({ size: 'letter' });
|
||||||
|
const form = pdf.getOrCreateForm();
|
||||||
|
const textField = form.createTextField('full_name');
|
||||||
|
const signatureField = form.createSignatureField('signed_signature');
|
||||||
|
|
||||||
|
page.drawField(textField, { x: 100, y: 700, width: 200, height: 24 });
|
||||||
|
signatureField.getDict().set('V', PdfString.fromString('fake-signature'));
|
||||||
|
|
||||||
|
return pdf.save();
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadAcroFormEnvelope = async ({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
payload = ACROFORM_DOCUMENT_PAYLOAD,
|
||||||
|
file = ACROFORM_FIXTURE,
|
||||||
|
fileName = 'acroform-import-test.pdf',
|
||||||
|
}: {
|
||||||
|
request: APIRequestContext;
|
||||||
|
token: string;
|
||||||
|
payload?: TCreateEnvelopePayload;
|
||||||
|
file?: Uint8Array;
|
||||||
|
fileName?: string;
|
||||||
|
}): Promise<TCreateEnvelopeResponse> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
formData.append('files', new File([file], fileName, { type: 'application/pdf' }));
|
||||||
|
|
||||||
|
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
multipart: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
return (await res.json()) as TCreateEnvelopeResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importAcroFormFieldsWithSession = ({
|
||||||
|
page,
|
||||||
|
teamId,
|
||||||
|
envelopeId,
|
||||||
|
}: {
|
||||||
|
page: Page;
|
||||||
|
teamId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
}) =>
|
||||||
|
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.importFromPdf`, {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-team-id': String(teamId),
|
||||||
|
},
|
||||||
|
data: JSON.stringify({ json: { envelopeId } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadEnvelopeForImport = async (envelopeId: string) =>
|
||||||
|
prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: envelopeId },
|
||||||
|
include: {
|
||||||
|
envelopeItems: { include: { documentData: true } },
|
||||||
|
recipients: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe.configure({
|
||||||
|
mode: 'parallel',
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('AcroForm Import', () => {
|
||||||
|
test('upload does not create fields and preserves widgets in the stored PDF', async ({ request }) => {
|
||||||
|
const { token } = await seedUserWithApiToken();
|
||||||
|
|
||||||
|
const response = await uploadAcroFormEnvelope({ request, token });
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: response.id },
|
||||||
|
include: {
|
||||||
|
envelopeItems: { include: { documentData: true } },
|
||||||
|
fields: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(envelope.fields).toHaveLength(0);
|
||||||
|
|
||||||
|
const pdfBuffer = await getFileServerSide(envelope.envelopeItems[0].documentData);
|
||||||
|
|
||||||
|
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replacement preserves widgets in the stored PDF for later import', async ({ request }) => {
|
||||||
|
const { token, user } = await seedUserWithApiToken();
|
||||||
|
|
||||||
|
const response = await uploadAcroFormEnvelope({ request, token });
|
||||||
|
|
||||||
|
const envelope = await loadEnvelopeForImport(response.id);
|
||||||
|
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
|
||||||
|
|
||||||
|
await UNSAFE_replaceEnvelopeItemPdf({
|
||||||
|
envelope,
|
||||||
|
recipients: envelope.recipients,
|
||||||
|
envelopeItemId: envelope.envelopeItems[0].id,
|
||||||
|
oldDocumentDataId,
|
||||||
|
data: {
|
||||||
|
title: 'Replacement AcroForm document',
|
||||||
|
file: new File([ACROFORM_FIXTURE], 'replacement-acroform.pdf', { type: 'application/pdf' }),
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
apiRequestMetadata: API_REQUEST_METADATA,
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: response.id },
|
||||||
|
include: {
|
||||||
|
envelopeItems: { include: { documentData: true } },
|
||||||
|
fields: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(after.fields).toHaveLength(0);
|
||||||
|
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
|
||||||
|
|
||||||
|
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
|
||||||
|
|
||||||
|
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import creates fields assigned to the signer, flattens the PDF, and emits audit logs', async ({ request }) => {
|
||||||
|
const { token } = await seedUserWithApiToken();
|
||||||
|
|
||||||
|
const response = await uploadAcroFormEnvelope({ request, token });
|
||||||
|
|
||||||
|
const envelope = await loadEnvelopeForImport(response.id);
|
||||||
|
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
|
||||||
|
|
||||||
|
const result = await UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||||
|
envelope,
|
||||||
|
apiRequestMetadata: API_REQUEST_METADATA,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.fieldsCreated).toBeGreaterThan(0);
|
||||||
|
expect(result.itemsProcessed).toBe(1);
|
||||||
|
|
||||||
|
const after = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: response.id },
|
||||||
|
include: {
|
||||||
|
envelopeItems: { include: { documentData: true } },
|
||||||
|
recipients: true,
|
||||||
|
fields: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(after.fields.length).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
|
||||||
|
|
||||||
|
for (const field of after.fields) {
|
||||||
|
const meta = field.fieldMeta as { source?: string } | null;
|
||||||
|
expect(meta?.source).toBe('acroform');
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditEntries = await prisma.documentAuditLog.findMany({
|
||||||
|
where: { envelopeId: after.id, type: 'FIELD_CREATED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(auditEntries.length).toBe(after.fields.length);
|
||||||
|
|
||||||
|
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
|
||||||
|
|
||||||
|
const flattenedPdf = await getFileServerSide(after.envelopeItems[0].documentData);
|
||||||
|
|
||||||
|
expect(await pdfHasFormFields(flattenedPdf)).toBe(false);
|
||||||
|
|
||||||
|
const oldRecord = await prisma.documentData.findUnique({ where: { id: oldDocumentDataId } });
|
||||||
|
|
||||||
|
expect(oldRecord).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import creates a placeholder Recipient 1 SIGNER when no recipients exist', async ({ request }) => {
|
||||||
|
const { token } = await seedUserWithApiToken();
|
||||||
|
|
||||||
|
const response = await uploadAcroFormEnvelope({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
payload: {
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
title: 'AcroForm document without recipients',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await loadEnvelopeForImport(response.id);
|
||||||
|
|
||||||
|
expect(envelope.recipients).toHaveLength(0);
|
||||||
|
|
||||||
|
await UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||||
|
envelope,
|
||||||
|
apiRequestMetadata: API_REQUEST_METADATA,
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: response.id },
|
||||||
|
include: { recipients: true, fields: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(after.recipients).toHaveLength(1);
|
||||||
|
expect(after.recipients[0].email).toBe('recipient.1@documenso.com');
|
||||||
|
expect(after.recipients[0].role).toBe(RecipientRole.SIGNER);
|
||||||
|
expect(after.fields.length).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import endpoint rejects template envelopes without mutating stored widgets', async ({ page, request }) => {
|
||||||
|
const { token, user, team } = await seedUserWithApiToken();
|
||||||
|
|
||||||
|
const response = await uploadAcroFormEnvelope({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
payload: {
|
||||||
|
type: EnvelopeType.TEMPLATE,
|
||||||
|
title: 'AcroForm template',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiSignin({ page, email: user.email });
|
||||||
|
|
||||||
|
const res = await importAcroFormFieldsWithSession({
|
||||||
|
page,
|
||||||
|
teamId: team.id,
|
||||||
|
envelopeId: response.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok()).toBeFalsy();
|
||||||
|
expect(res.status()).toBe(404);
|
||||||
|
|
||||||
|
const after = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: response.id },
|
||||||
|
include: {
|
||||||
|
envelopeItems: { include: { documentData: true } },
|
||||||
|
fields: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(after.fields).toHaveLength(0);
|
||||||
|
|
||||||
|
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
|
||||||
|
|
||||||
|
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('import does not duplicate fields when signed signatures prevent flattening', async ({ request }) => {
|
||||||
|
const { token } = await seedUserWithApiToken();
|
||||||
|
const signedPdf = await createSignedSignatureAcroFormPdf();
|
||||||
|
|
||||||
|
const response = await uploadAcroFormEnvelope({
|
||||||
|
request,
|
||||||
|
token,
|
||||||
|
file: signedPdf,
|
||||||
|
fileName: 'signed-acroform.pdf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await loadEnvelopeForImport(response.id);
|
||||||
|
|
||||||
|
const firstResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||||
|
envelope,
|
||||||
|
apiRequestMetadata: API_REQUEST_METADATA,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(firstResult.fieldsCreated).toBeGreaterThan(0);
|
||||||
|
expect(firstResult.itemsProcessed).toBe(1);
|
||||||
|
expect(firstResult.signedSignatureCount).toBe(1);
|
||||||
|
|
||||||
|
const afterFirst = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: response.id },
|
||||||
|
include: {
|
||||||
|
envelopeItems: { include: { documentData: true } },
|
||||||
|
fields: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const firstFieldCount = afterFirst.fields.length;
|
||||||
|
|
||||||
|
const preservedPdf = await getFileServerSide(afterFirst.envelopeItems[0].documentData);
|
||||||
|
|
||||||
|
expect(await pdfHasFormFields(preservedPdf)).toBe(true);
|
||||||
|
|
||||||
|
const secondEnvelope = await loadEnvelopeForImport(response.id);
|
||||||
|
|
||||||
|
const secondResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||||
|
envelope: secondEnvelope,
|
||||||
|
apiRequestMetadata: API_REQUEST_METADATA,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondResult.fieldsCreated).toBe(0);
|
||||||
|
expect(secondResult.itemsProcessed).toBe(0);
|
||||||
|
|
||||||
|
const afterSecond = await prisma.envelope.findUniqueOrThrow({
|
||||||
|
where: { id: response.id },
|
||||||
|
include: { fields: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(afterSecond.fields).toHaveLength(firstFieldCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -93,7 +93,7 @@ test.describe('Form Flattening', () => {
|
|||||||
const formFieldsPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/form-fields-test.pdf'));
|
const formFieldsPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/form-fields-test.pdf'));
|
||||||
|
|
||||||
test.describe('Envelope Creation (DOCUMENT type)', () => {
|
test.describe('Envelope Creation (DOCUMENT type)', () => {
|
||||||
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
|
test('should preserve form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
const { token } = await createApiToken({
|
const { token } = await createApiToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -136,16 +136,16 @@ test.describe('Form Flattening', () => {
|
|||||||
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
|
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
|
||||||
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
|
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
|
||||||
|
|
||||||
// Get the PDF and verify form fields are flattened
|
|
||||||
const documentData = envelope.envelopeItems[0].documentData;
|
const documentData = envelope.envelopeItems[0].documentData;
|
||||||
const pdfBuffer = await getFileServerSide(documentData);
|
const pdfBuffer = await getFileServerSide(documentData);
|
||||||
|
|
||||||
const hasFormFields = await pdfHasFormFields(pdfBuffer);
|
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||||
|
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe(
|
||||||
expect(hasFormFields).toBe(false);
|
TEST_FORM_VALUES[FORM_FIELDS.TEXT_FIELD],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
|
test('should preserve form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
const { token } = await createApiToken({
|
const { token } = await createApiToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -157,7 +157,6 @@ test.describe('Form Flattening', () => {
|
|||||||
const payload: TCreateEnvelopePayload = {
|
const payload: TCreateEnvelopePayload = {
|
||||||
type: EnvelopeType.DOCUMENT,
|
type: EnvelopeType.DOCUMENT,
|
||||||
title: 'Document without Form Values',
|
title: 'Document without Form Values',
|
||||||
// No formValues - but form should still be flattened for DOCUMENT type
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -184,13 +183,10 @@ test.describe('Form Flattening', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the PDF and verify form fields are flattened
|
|
||||||
const documentData = envelope.envelopeItems[0].documentData;
|
const documentData = envelope.envelopeItems[0].documentData;
|
||||||
const pdfBuffer = await getFileServerSide(documentData);
|
const pdfBuffer = await getFileServerSide(documentData);
|
||||||
|
|
||||||
const hasFormFields = await pdfHasFormFields(pdfBuffer);
|
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||||
|
|
||||||
expect(hasFormFields).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -747,11 +743,10 @@ test.describe('Form Flattening', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form should still be flattened for DOCUMENT type
|
|
||||||
const documentData = envelope.envelopeItems[0].documentData;
|
const documentData = envelope.envelopeItems[0].documentData;
|
||||||
const pdfBuffer = await getFileServerSide(documentData);
|
const pdfBuffer = await getFileServerSide(documentData);
|
||||||
|
|
||||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
|
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle partial formValues (only some fields)', async ({ request }) => {
|
test('should handle partial formValues (only some fields)', async ({ request }) => {
|
||||||
@@ -798,11 +793,11 @@ test.describe('Form Flattening', () => {
|
|||||||
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
|
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form should still be flattened
|
|
||||||
const documentData = envelope.envelopeItems[0].documentData;
|
const documentData = envelope.envelopeItems[0].documentData;
|
||||||
const pdfBuffer = await getFileServerSide(documentData);
|
const pdfBuffer = await getFileServerSide(documentData);
|
||||||
|
|
||||||
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
|
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
|
||||||
|
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe('Only this field');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type EnvelopeRenderItem = {
|
|||||||
title: string;
|
title: string;
|
||||||
order: number;
|
order: number;
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
|
documentDataId: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The PDF data to render.
|
* The PDF data to render.
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const UNSAFE_createEnvelopeItems = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalized = await normalizePdf(buffer, {
|
const normalized = await normalizePdf(buffer, {
|
||||||
flattenForm: envelope.type !== 'TEMPLATE',
|
flattenForm: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
|
||||||
|
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
|
||||||
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||||
|
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
|
import { nanoid } from '../../universal/id';
|
||||||
|
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||||
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
import {
|
||||||
|
type AcroFormExtractionResult,
|
||||||
|
type AcroFormSkipReason,
|
||||||
|
convertAcroFormFieldsToFieldInputs,
|
||||||
|
extractAcroFormFieldsFromPDF,
|
||||||
|
} from '../pdf/acroform-fields';
|
||||||
|
import { normalizePdf } from '../pdf/normalize-pdf';
|
||||||
|
|
||||||
|
type UnsafeImportAcroFormFieldsOptions = {
|
||||||
|
envelope: Pick<Envelope, 'id' | 'type' | 'formValues'> & {
|
||||||
|
envelopeItems: (Pick<EnvelopeItem, 'id' | 'title' | 'documentDataId'> & {
|
||||||
|
documentData: DocumentData;
|
||||||
|
})[];
|
||||||
|
recipients: Recipient[];
|
||||||
|
};
|
||||||
|
apiRequestMetadata: ApiRequestMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PerItemSkip = {
|
||||||
|
envelopeItemId: string;
|
||||||
|
envelopeItemTitle: string;
|
||||||
|
reason: AcroFormSkipReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportAcroFormFieldsResult = {
|
||||||
|
itemsProcessed: number;
|
||||||
|
fieldsCreated: number;
|
||||||
|
unsupportedCount: number;
|
||||||
|
signedSignatureCount: number;
|
||||||
|
skippedItems: PerItemSkip[];
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreparedItem = {
|
||||||
|
envelopeItemId: string;
|
||||||
|
envelopeItemTitle: string;
|
||||||
|
oldDocumentDataId: string;
|
||||||
|
extraction: AcroFormExtractionResult;
|
||||||
|
newDocumentData?: DocumentData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({
|
||||||
|
envelope,
|
||||||
|
apiRequestMetadata,
|
||||||
|
}: UnsafeImportAcroFormFieldsOptions): Promise<ImportAcroFormFieldsResult> => {
|
||||||
|
if (envelope.type !== EnvelopeType.DOCUMENT) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'AcroForm import is only supported for document envelopes',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepared: PreparedItem[] = await Promise.all(
|
||||||
|
envelope.envelopeItems.map(async (item): Promise<PreparedItem> => {
|
||||||
|
const buffer = await getFileServerSide(item.documentData);
|
||||||
|
|
||||||
|
const extraction = await extractAcroFormFieldsFromPDF(Buffer.from(buffer), {
|
||||||
|
formValuesProvided: Boolean(envelope.formValues),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (extraction.skipReason) {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
event: 'acroform-import.skip',
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
envelopeItemTitle: item.title,
|
||||||
|
reason: extraction.skipReason,
|
||||||
|
},
|
||||||
|
'AcroForm extraction skipped',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraction.unsupported.length > 0) {
|
||||||
|
const byReason: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const entry of extraction.unsupported) {
|
||||||
|
byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
event: 'acroform-import.unsupported',
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
envelopeItemTitle: item.title,
|
||||||
|
count: extraction.unsupported.length,
|
||||||
|
byReason,
|
||||||
|
},
|
||||||
|
'AcroForm import skipped unsupported widgets',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraction.hasSignedSignature) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
event: 'acroform-import.signed-pdf-no-flatten',
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
envelopeItemTitle: item.title,
|
||||||
|
},
|
||||||
|
'Signed AcroForm signature detected — skipping flatten to preserve signature',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: PreparedItem = {
|
||||||
|
envelopeItemId: item.id,
|
||||||
|
envelopeItemTitle: item.title,
|
||||||
|
oldDocumentDataId: item.documentDataId,
|
||||||
|
extraction,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (extraction.fields.length === 0 || extraction.hasSignedSignature) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flattened = await normalizePdf(Buffer.from(buffer), {
|
||||||
|
flattenForm: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { documentData: newDocumentData } = await putPdfFileServerSide({
|
||||||
|
name: item.title,
|
||||||
|
type: 'application/pdf',
|
||||||
|
arrayBuffer: async () => Promise.resolve(flattened),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
newDocumentData,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalFieldsToCreate = prepared.reduce((sum, p) => sum + p.extraction.fields.length, 0);
|
||||||
|
const unsupportedCount = prepared.reduce((sum, p) => sum + p.extraction.unsupported.length, 0);
|
||||||
|
const signedSignatureCount = prepared.filter((p) => p.extraction.hasSignedSignature).length;
|
||||||
|
|
||||||
|
const skippedItems: PerItemSkip[] = [];
|
||||||
|
|
||||||
|
for (const p of prepared) {
|
||||||
|
const reason = p.extraction.skipReason;
|
||||||
|
|
||||||
|
if (!reason) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
skippedItems.push({
|
||||||
|
envelopeItemId: p.envelopeItemId,
|
||||||
|
envelopeItemTitle: p.envelopeItemTitle,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalFieldsToCreate === 0) {
|
||||||
|
return {
|
||||||
|
itemsProcessed: 0,
|
||||||
|
fieldsCreated: 0,
|
||||||
|
unsupportedCount,
|
||||||
|
signedSignatureCount,
|
||||||
|
skippedItems,
|
||||||
|
fields: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { createdFields, importedItemsCount } = await prisma.$transaction(async (tx) => {
|
||||||
|
const pickFirstSignableRecipient = (recipients: Pick<Recipient, 'id' | 'email' | 'role' | 'signingOrder'>[]) => {
|
||||||
|
const signable = recipients.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];
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedItemIds = prepared
|
||||||
|
.filter((item) => item.extraction.hasSignedSignature && item.extraction.fields.length > 0)
|
||||||
|
.map((item) => item.envelopeItemId);
|
||||||
|
|
||||||
|
const alreadyImportedSignedItemIds = new Set<string>();
|
||||||
|
|
||||||
|
if (signedItemIds.length > 0) {
|
||||||
|
const existingImportedFields = await tx.field.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
envelopeItemId: {
|
||||||
|
in: signedItemIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
envelopeItemId: true,
|
||||||
|
fieldMeta: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const field of existingImportedFields) {
|
||||||
|
const fieldMeta = field.fieldMeta;
|
||||||
|
|
||||||
|
if (
|
||||||
|
fieldMeta &&
|
||||||
|
typeof fieldMeta === 'object' &&
|
||||||
|
!Array.isArray(fieldMeta) &&
|
||||||
|
(fieldMeta as { source?: unknown }).source === 'acroform'
|
||||||
|
) {
|
||||||
|
alreadyImportedSignedItemIds.add(field.envelopeItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsToImport = prepared.filter((item) => {
|
||||||
|
if (item.extraction.fields.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !(item.extraction.hasSignedSignature && alreadyImportedSignedItemIds.has(item.envelopeItemId));
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdFields: Field[] = [];
|
||||||
|
|
||||||
|
if (itemsToImport.length === 0) {
|
||||||
|
return { createdFields, importedItemsCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let recipient = pickFirstSignableRecipient(
|
||||||
|
await tx.recipient.findMany({
|
||||||
|
where: { envelopeId: envelope.id },
|
||||||
|
select: { id: true, email: true, role: true, signingOrder: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
const placeholderEmail = 'recipient.1@documenso.com';
|
||||||
|
|
||||||
|
recipient = 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,
|
||||||
|
},
|
||||||
|
select: { id: true, email: true, role: true, signingOrder: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let importedItemsCount = 0;
|
||||||
|
|
||||||
|
for (const item of itemsToImport) {
|
||||||
|
if (item.newDocumentData) {
|
||||||
|
await tx.envelopeItem.update({
|
||||||
|
where: { id: item.envelopeItemId },
|
||||||
|
data: { documentDataId: item.newDocumentData.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsToCreate = convertAcroFormFieldsToFieldInputs(
|
||||||
|
item.extraction.fields,
|
||||||
|
() => recipient,
|
||||||
|
item.envelopeItemId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemCreatedFields = await tx.field.createManyAndReturn({
|
||||||
|
data: fieldsToCreate.map((field) => ({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
envelopeItemId: item.envelopeItemId,
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
createdFields.push(...itemCreatedFields);
|
||||||
|
importedItemsCount += 1;
|
||||||
|
|
||||||
|
if (envelope.type === EnvelopeType.DOCUMENT) {
|
||||||
|
await tx.documentAuditLog.createMany({
|
||||||
|
data: itemCreatedFields.map((createdField) =>
|
||||||
|
createDocumentAuditLogData({
|
||||||
|
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
metadata: apiRequestMetadata,
|
||||||
|
data: {
|
||||||
|
fieldId: createdField.secondaryId,
|
||||||
|
fieldRecipientEmail: recipient.email,
|
||||||
|
fieldRecipientId: createdField.recipientId,
|
||||||
|
fieldType: createdField.type,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createdFields, importedItemsCount };
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
prepared
|
||||||
|
.filter((p) => p.newDocumentData !== undefined)
|
||||||
|
.map((p) =>
|
||||||
|
prisma.documentData.delete({ where: { id: p.oldDocumentDataId } }).catch((err) => {
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
event: 'acroform-import.delete-old-document-data-failed',
|
||||||
|
envelopeItemId: p.envelopeItemId,
|
||||||
|
oldDocumentDataId: p.oldDocumentDataId,
|
||||||
|
err,
|
||||||
|
},
|
||||||
|
'Failed to delete orphaned DocumentData after AcroForm import',
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemsProcessed: importedItemsCount,
|
||||||
|
fieldsCreated: createdFields.length,
|
||||||
|
unsupportedCount,
|
||||||
|
signedSignatureCount,
|
||||||
|
skippedItems,
|
||||||
|
fields: createdFields,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -82,7 +82,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalized = await normalizePdf(buffer, {
|
const normalized = await normalizePdf(buffer, {
|
||||||
flattenForm: envelope.type !== 'TEMPLATE',
|
flattenForm: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
|
||||||
|
|||||||
@@ -0,0 +1,834 @@
|
|||||||
|
import {
|
||||||
|
PDF,
|
||||||
|
type PDFPage,
|
||||||
|
type PdfDict,
|
||||||
|
type PdfObject,
|
||||||
|
type PdfRef,
|
||||||
|
type PdfStream,
|
||||||
|
type PdfString,
|
||||||
|
} 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function shape that follows a {@link PdfRef} to the referenced object.
|
||||||
|
*
|
||||||
|
* `@libpdf/core` does not re-export the `RefResolver` type alias, so we redeclare
|
||||||
|
* it locally. Built via {@link makeResolver} from a loaded {@link PDF}.
|
||||||
|
*/
|
||||||
|
type RefResolver = (ref: PdfRef) => PdfObject | null;
|
||||||
|
|
||||||
|
const makeResolver =
|
||||||
|
(pdfDoc: PDF): RefResolver =>
|
||||||
|
(ref: PdfRef) =>
|
||||||
|
pdfDoc.context.resolve(ref);
|
||||||
|
|
||||||
|
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;
|
||||||
|
const ACROFORM_FIELD_SOURCE = 'acroform';
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect XFA-hybrid PDFs by inspecting the catalog's `/AcroForm` dict for an
|
||||||
|
* `/XFA` key.
|
||||||
|
*
|
||||||
|
* Uses public accessors (`pdf.context.catalog.getDict()`) and a {@link RefResolver}
|
||||||
|
* so an indirect `/AcroForm` entry is followed. Returns `false` on any error
|
||||||
|
* (e.g. malformed catalog) so the caller can fall through to normal extraction.
|
||||||
|
*/
|
||||||
|
const hasXfa = (pdfDoc: PDF, resolver: RefResolver): boolean => {
|
||||||
|
try {
|
||||||
|
const catalogDict = pdfDoc.context.catalog.getDict();
|
||||||
|
const acroFormDict = catalogDict.getDict('AcroForm', resolver);
|
||||||
|
|
||||||
|
return Boolean(acroFormDict?.has('XFA'));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateFieldByName = (name: string | null | undefined): boolean => !!name && DATE_NAME_PATTERN.test(name);
|
||||||
|
|
||||||
|
const isNumberFieldByName = (name: string | null | undefined): boolean => !!name && NUMBER_NAME_PATTERN.test(name);
|
||||||
|
|
||||||
|
const isEmailFieldByName = (name: string | null | undefined): boolean => !!name && EMAIL_NAME_PATTERN.test(name);
|
||||||
|
|
||||||
|
const isNameFieldByName = (name: string | null | undefined): boolean => !!name && NAME_NAME_PATTERN.test(name);
|
||||||
|
|
||||||
|
const isInitialsFieldByName = (name: string | null | undefined): boolean => !!name && INITIALS_NAME_PATTERN.test(name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. The action dict and its `/F` entry are frequently stored
|
||||||
|
* as indirect refs in real-world PDFs, so a {@link RefResolver} MUST be
|
||||||
|
* threaded through the lookups. We do a string-contains check on the script
|
||||||
|
* to avoid pulling in a JS parser.
|
||||||
|
*/
|
||||||
|
const getTextFieldFormatHint = (fieldDict: PdfDict, resolver: RefResolver): 'date' | 'number' | null => {
|
||||||
|
try {
|
||||||
|
const formatDict = fieldDict.getDict('AA', resolver)?.getDict('F', resolver);
|
||||||
|
|
||||||
|
if (!formatDict) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const js = formatDict.get('JS', resolver);
|
||||||
|
|
||||||
|
if (!js || typeof js !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let script: string;
|
||||||
|
|
||||||
|
if (js.type === 'string') {
|
||||||
|
script = (js as PdfString).asString();
|
||||||
|
} else if (js.type === 'stream') {
|
||||||
|
script = new TextDecoder().decode((js as PdfStream).getDecodedData());
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ResolvedTextDocumensoType =
|
||||||
|
| typeof FieldType.TEXT
|
||||||
|
| typeof FieldType.DATE
|
||||||
|
| typeof FieldType.NUMBER
|
||||||
|
| typeof FieldType.EMAIL
|
||||||
|
| typeof FieldType.NAME
|
||||||
|
| typeof FieldType.INITIALS;
|
||||||
|
|
||||||
|
const resolveTextSubtype = (
|
||||||
|
field: FormFieldWithDict,
|
||||||
|
resolver: RefResolver,
|
||||||
|
): {
|
||||||
|
documensoType: ResolvedTextDocumensoType;
|
||||||
|
} => {
|
||||||
|
const candidateNames = [field.partialName, field.alternateName];
|
||||||
|
|
||||||
|
const formatHint = getTextFieldFormatHint(field.acroField(), resolver);
|
||||||
|
|
||||||
|
// AcroForm format actions take precedence over name tokens — Adobe set them
|
||||||
|
// explicitly, so they're a stronger signal than a heuristic regex hit.
|
||||||
|
if (formatHint === 'date') {
|
||||||
|
return { documensoType: FieldType.DATE };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formatHint === 'number') {
|
||||||
|
return { documensoType: FieldType.NUMBER };
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLen = field.acroField().getNumber('MaxLen', resolver)?.value ?? Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
if (candidateNames.some(isDateFieldByName)) {
|
||||||
|
return { documensoType: FieldType.DATE };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxLen <= 10 && candidateNames.some(isNumberFieldByName)) {
|
||||||
|
return { documensoType: FieldType.NUMBER };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateNames.some(isEmailFieldByName)) {
|
||||||
|
return { documensoType: FieldType.EMAIL };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateNames.some(isNameFieldByName)) {
|
||||||
|
return { documensoType: FieldType.NAME };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidateNames.some(isInitialsFieldByName)) {
|
||||||
|
return { documensoType: FieldType.INITIALS };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { documensoType: FieldType.TEXT };
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickLabel = (field: FormFieldWithDict): string | undefined => {
|
||||||
|
// /TU is the human-facing tooltip/label; /T is the internal field identifier.
|
||||||
|
return field.alternateName || 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_FIELD_SOURCE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTextFieldAndMeta = (
|
||||||
|
field: FormFieldWithDict,
|
||||||
|
documensoType: ResolvedTextDocumensoType,
|
||||||
|
defaultText: string | undefined,
|
||||||
|
): TFieldAndMeta => {
|
||||||
|
const label = pickLabel(field);
|
||||||
|
const required = field.isRequired() || undefined;
|
||||||
|
const readOnly = field.isReadOnly() || undefined;
|
||||||
|
|
||||||
|
const defaultValue = defaultText && defaultText.length > 0 ? defaultText : undefined;
|
||||||
|
if (documensoType === FieldType.NUMBER) {
|
||||||
|
const fieldMeta: TNumberFieldMeta = {
|
||||||
|
...FIELD_NUMBER_META_DEFAULT_VALUES,
|
||||||
|
label: label ?? FIELD_NUMBER_META_DEFAULT_VALUES.label,
|
||||||
|
required,
|
||||||
|
readOnly,
|
||||||
|
source: ACROFORM_FIELD_SOURCE,
|
||||||
|
value: defaultValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ZEnvelopeFieldAndMetaSchema.parse({ type: documensoType, fieldMeta });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documensoType === FieldType.TEXT) {
|
||||||
|
const fieldMeta: TTextFieldMeta = {
|
||||||
|
...FIELD_TEXT_META_DEFAULT_VALUES,
|
||||||
|
label: label ?? FIELD_TEXT_META_DEFAULT_VALUES.label,
|
||||||
|
required,
|
||||||
|
readOnly,
|
||||||
|
source: ACROFORM_FIELD_SOURCE,
|
||||||
|
text: defaultValue ?? FIELD_TEXT_META_DEFAULT_VALUES.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ZEnvelopeFieldAndMetaSchema.parse({ type: documensoType, fieldMeta });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documensoType === FieldType.DATE) {
|
||||||
|
return ZEnvelopeFieldAndMetaSchema.parse({
|
||||||
|
type: documensoType,
|
||||||
|
fieldMeta: {
|
||||||
|
...FIELD_DATE_META_DEFAULT_VALUES,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
readOnly,
|
||||||
|
source: ACROFORM_FIELD_SOURCE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documensoType === FieldType.EMAIL) {
|
||||||
|
return ZEnvelopeFieldAndMetaSchema.parse({
|
||||||
|
type: documensoType,
|
||||||
|
fieldMeta: {
|
||||||
|
...FIELD_EMAIL_META_DEFAULT_VALUES,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
readOnly,
|
||||||
|
source: ACROFORM_FIELD_SOURCE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (documensoType === FieldType.NAME) {
|
||||||
|
return ZEnvelopeFieldAndMetaSchema.parse({
|
||||||
|
type: documensoType,
|
||||||
|
fieldMeta: {
|
||||||
|
...FIELD_NAME_META_DEFAULT_VALUES,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
readOnly,
|
||||||
|
source: ACROFORM_FIELD_SOURCE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZEnvelopeFieldAndMetaSchema.parse({
|
||||||
|
type: documensoType,
|
||||||
|
fieldMeta: {
|
||||||
|
...FIELD_INITIALS_META_DEFAULT_VALUES,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
readOnly,
|
||||||
|
source: ACROFORM_FIELD_SOURCE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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_FIELD_SOURCE,
|
||||||
|
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_FIELD_SOURCE,
|
||||||
|
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_FIELD_SOURCE,
|
||||||
|
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<PdfRef, { index: number; page: PDFPage }>,
|
||||||
|
): {
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
|
||||||
|
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, pure XFA forms
|
||||||
|
* with no AcroForm widgets, and on any internal error (with `skipReason` set so
|
||||||
|
* callers can log).
|
||||||
|
*/
|
||||||
|
export const extractAcroFormFieldsFromPDF = async (
|
||||||
|
pdf: Buffer,
|
||||||
|
options: ExtractAcroFormOptions = {},
|
||||||
|
): Promise<AcroFormExtractionResult> => {
|
||||||
|
try {
|
||||||
|
const pdfDoc = await PDF.load(new Uint8Array(pdf));
|
||||||
|
|
||||||
|
if (pdfDoc.isEncrypted) {
|
||||||
|
return EMPTY_RESULT('encrypted');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolver = makeResolver(pdfDoc);
|
||||||
|
|
||||||
|
const hasXfaForm = hasXfa(pdfDoc, resolver);
|
||||||
|
const form = pdfDoc.getForm();
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
return EMPTY_RESULT(hasXfaForm ? 'xfa-hybrid' : 'no-form');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formFields = form.getFields();
|
||||||
|
|
||||||
|
if (hasXfaForm && formFields.length === 0) {
|
||||||
|
return EMPTY_RESULT('xfa-hybrid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
const pageByRef = new Map<PdfRef, { index: number; page: PDFPage }>();
|
||||||
|
|
||||||
|
pages.forEach((page, index) => {
|
||||||
|
pageByRef.set(page.ref, { index, page });
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields: AcroFormFieldImportInfo[] = [];
|
||||||
|
const unsupported: AcroFormUnsupportedFieldInfo[] = [];
|
||||||
|
let hasSignedSignature = false;
|
||||||
|
const usePdfDefaults = !options.formValuesProvided;
|
||||||
|
const addUnsupported = (fieldName: string, acroFormType: string, reason: AcroFormUnsupportedReason): void => {
|
||||||
|
unsupported.push({ fieldName, acroFormType, reason });
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const field of formFields) {
|
||||||
|
const acroFormType = field.type;
|
||||||
|
|
||||||
|
if (
|
||||||
|
acroFormType === 'listbox' ||
|
||||||
|
acroFormType === 'button' ||
|
||||||
|
acroFormType === 'unknown' ||
|
||||||
|
acroFormType === 'non-terminal'
|
||||||
|
) {
|
||||||
|
addUnsupported(field.name, acroFormType, '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;
|
||||||
|
addUnsupported(field.name, acroFormType, 'signed-signature');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formField = field as unknown as FormFieldWithDict;
|
||||||
|
const widgets = formField.getWidgets();
|
||||||
|
const { matched, unmatched } = resolveWidgetPages(widgets, pageByRef);
|
||||||
|
|
||||||
|
for (let i = 0; i < unmatched.length; i += 1) {
|
||||||
|
addUnsupported(field.name, acroFormType, 'no-page-match');
|
||||||
|
}
|
||||||
|
|
||||||
|
let widgetCounter = 0;
|
||||||
|
|
||||||
|
for (const { widget, pageIndex, page } of matched) {
|
||||||
|
if (widget.isHidden()) {
|
||||||
|
addUnsupported(field.name, acroFormType, 'hidden');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { geometry, reason } = resolveGeometry(widget, pageIndex, page);
|
||||||
|
|
||||||
|
if (!geometry) {
|
||||||
|
addUnsupported(field.name, acroFormType, reason ?? 'zero-size');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fieldAndMeta: TFieldAndMeta;
|
||||||
|
|
||||||
|
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 { documensoType } = resolveTextSubtype(formField, resolver);
|
||||||
|
const defaultText = usePdfDefaults ? textField.getValue?.() || textField.getDefaultValue?.() || '' : '';
|
||||||
|
fieldAndMeta = buildTextFieldAndMeta(formField, documensoType, 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 {
|
||||||
|
addUnsupported(field.name, acroFormType, 'unsupported-type');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
source: ACROFORM_FIELD_SOURCE,
|
||||||
|
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<Recipient, 'id'>,
|
||||||
|
envelopeItemId?: string,
|
||||||
|
): FieldToCreate[] => {
|
||||||
|
return sortFieldsForCreate(fields).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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -72,6 +72,7 @@ export const ZBaseFieldMeta = z.object({
|
|||||||
readOnly: z.boolean().optional(),
|
readOnly: z.boolean().optional(),
|
||||||
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
|
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
|
||||||
overflow: ZFieldOverflowMode.optional(),
|
overflow: ZFieldOverflowMode.optional(),
|
||||||
|
source: z.enum(['acroform']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
|
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-plac
|
|||||||
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||||
import { EnvelopeType } from '@prisma/client';
|
|
||||||
import type { Logger } from 'pino';
|
import type { Logger } from 'pino';
|
||||||
|
|
||||||
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
|
||||||
@@ -120,7 +119,7 @@ export const createEnvelopeRouteCaller = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalized = await normalizePdf(pdf, {
|
const normalized = await normalizePdf(pdf, {
|
||||||
flattenForm: type !== EnvelopeType.TEMPLATE,
|
flattenForm: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Todo: Embeds - Might need to add this for client-side embeds in the future.
|
// Todo: Embeds - Might need to add this for client-side embeds in the future.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
|
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||||
|
|
||||||
|
import { authenticatedProcedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZImportAcroFormFieldsRequestSchema,
|
||||||
|
ZImportAcroFormFieldsResponseSchema,
|
||||||
|
} from './import-acroform-fields.types';
|
||||||
|
|
||||||
|
export const importAcroFormFieldsRoute = authenticatedProcedure
|
||||||
|
.input(ZImportAcroFormFieldsRequestSchema)
|
||||||
|
.output(ZImportAcroFormFieldsResponseSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { user, teamId, metadata } = ctx;
|
||||||
|
const { envelopeId } = input;
|
||||||
|
|
||||||
|
ctx.logger.info({ input: { envelopeId } });
|
||||||
|
|
||||||
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
|
id: { type: 'envelopeId', id: envelopeId },
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
userId: user.id,
|
||||||
|
teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findUnique({
|
||||||
|
where: envelopeWhereInput,
|
||||||
|
include: {
|
||||||
|
recipients: true,
|
||||||
|
envelopeItems: {
|
||||||
|
include: { documentData: true },
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.internalVersion !== 2) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'AcroForm import is only supported for version 2 envelopes',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.status !== DocumentStatus.DRAFT) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'AcroForm import is only allowed while the envelope is in draft',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.envelopeItems.length === 0) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Envelope has no items to import from',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return UNSAFE_importAcroFormFieldsFromEnvelope({
|
||||||
|
envelope,
|
||||||
|
apiRequestMetadata: metadata,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZImportAcroFormFieldsRequestSchema = z.object({
|
||||||
|
envelopeId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZImportAcroFormFieldsResponseSchema = z.object({
|
||||||
|
itemsProcessed: z.number().int().min(0),
|
||||||
|
fieldsCreated: z.number().int().min(0),
|
||||||
|
unsupportedCount: z.number().int().min(0),
|
||||||
|
signedSignatureCount: z.number().int().min(0),
|
||||||
|
skippedItems: z.array(
|
||||||
|
z.object({
|
||||||
|
envelopeItemId: z.string(),
|
||||||
|
envelopeItemTitle: z.string(),
|
||||||
|
reason: z.enum(['encrypted', 'xfa-hybrid', 'no-form', 'error']),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
fields: z.array(ZEnvelopeFieldSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TImportAcroFormFieldsRequest = z.infer<typeof ZImportAcroFormFieldsRequestSchema>;
|
||||||
|
export type TImportAcroFormFieldsResponse = z.infer<typeof ZImportAcroFormFieldsResponseSchema>;
|
||||||
@@ -34,6 +34,7 @@ import { getEnvelopeRoute } from './get-envelope';
|
|||||||
import { getEnvelopeItemsRoute } from './get-envelope-items';
|
import { getEnvelopeItemsRoute } from './get-envelope-items';
|
||||||
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
|
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
|
||||||
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
|
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
|
||||||
|
import { importAcroFormFieldsRoute } from './import-acroform-fields';
|
||||||
import { redistributeEnvelopeRoute } from './redistribute-envelope';
|
import { redistributeEnvelopeRoute } from './redistribute-envelope';
|
||||||
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
|
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
|
||||||
import { saveAsTemplateRoute } from './save-as-template';
|
import { saveAsTemplateRoute } from './save-as-template';
|
||||||
@@ -83,6 +84,7 @@ export const envelopeRouter = router({
|
|||||||
delete: deleteEnvelopeFieldRoute,
|
delete: deleteEnvelopeFieldRoute,
|
||||||
set: setEnvelopeFieldsRoute,
|
set: setEnvelopeFieldsRoute,
|
||||||
sign: signEnvelopeFieldRoute,
|
sign: signEnvelopeFieldRoute,
|
||||||
|
importFromPdf: importAcroFormFieldsRoute,
|
||||||
},
|
},
|
||||||
find: findEnvelopesRoute,
|
find: findEnvelopesRoute,
|
||||||
auditLog: {
|
auditLog: {
|
||||||
|
|||||||
Reference in New Issue
Block a user