feat(pdf): import AcroForm widgets as Documenso fields on upload

Detect AcroForm widgets (text, checkbox, radio, dropdown, signature) at upload
time and reuse their geometry as Documenso fields instead of stripping them via
form.flatten(). Imported fields land in the editor as ordinary Field rows
assigned to the first signable recipient, removing the manual re-placement step
users hit when preparing PDFs in Adobe Acrobat.

Extraction runs before normalizePdf so widget geometry is still readable.
Text fields go through a name+format heuristic that maps DATE/NUMBER/EMAIL/
NAME/INITIALS/TEXT, with AcroForm /AA format actions taking precedence over
name tokens. Coordinates are converted via per-rotation transforms (0/90/180/
270) against the rendered page dimensions; widgets fully off-page are
dropped, partial overlap is clamped. Signed signatures (SignatureField.
isSigned()) are detected and skip both the import and the form flatten so
the signature stays valid. Encrypted PDFs, XFA hybrids, malformed PDFs, and
internal extractor errors all return an empty result with skipReason set so
the upload proceeds untouched.

Every imported field carries fieldMeta.source = 'acroform' (new optional on
ZBaseFieldMeta) for future provenance queries. DOCUMENT envelopes emit a
per-field FIELD_CREATED audit entry matching create-envelope-fields.ts.
Recipient assignment picks the first Recipient with role SIGNER or APPROVER
sorted by (signingOrder asc nulls last, id asc); when no signable recipient
exists, a placeholder Recipient 1 SIGNER is created mirroring the
placeholder-pipeline behaviour.
This commit is contained in:
ephraimduncan
2026-05-21 04:05:12 +00:00
parent c0ea4c60e4
commit b8a11df768
13 changed files with 5006 additions and 4 deletions
@@ -0,0 +1,130 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { expect, test } from '@playwright/test';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
const ACROFORM_FIXTURE = fs.readFileSync(path.join(__dirname, '../../../../assets/acroform-import-test.pdf'));
test.describe.configure({
mode: 'parallel',
});
test.describe('AcroForm Import', () => {
test('imports AcroForm widgets as Documenso fields assigned to the provided signer', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document',
recipients: [
{
email: 'signer@example.com',
name: 'Signer',
role: RecipientRole.SIGNER,
},
],
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([ACROFORM_FIXTURE], 'acroform-import-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
fields: true,
},
});
expect(envelope.recipients).toHaveLength(1);
expect(envelope.recipients[0].email).toBe('signer@example.com');
// Every imported field is assigned to the single signer.
expect(envelope.fields.length).toBeGreaterThanOrEqual(8);
expect(envelope.fields.every((f) => f.recipientId === envelope.recipients[0].id)).toBe(true);
// Every imported field has source: 'acroform' on its fieldMeta.
for (const field of envelope.fields) {
const meta = field.fieldMeta as { source?: string } | null;
expect(meta?.source).toBe('acroform');
}
// FIELD_CREATED audit log entries match the number of imported fields.
const auditEntries = await prisma.documentAuditLog.findMany({
where: { envelopeId: envelope.id, type: 'FIELD_CREATED' },
});
expect(auditEntries.length).toBe(envelope.fields.length);
});
test('creates a placeholder Recipient 1 SIGNER when no recipients are provided', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document without recipients',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([ACROFORM_FIXTURE], 'acroform-import-test.pdf', { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
recipients: true,
fields: true,
},
});
expect(envelope.recipients).toHaveLength(1);
expect(envelope.recipients[0].email).toBe('recipient.1@documenso.com');
expect(envelope.recipients[0].role).toBe(RecipientRole.SIGNER);
expect(envelope.fields.length).toBeGreaterThanOrEqual(8);
expect(envelope.fields.every((f) => f.recipientId === envelope.recipients[0].id)).toBe(true);
});
});
@@ -1,3 +1,7 @@
import {
convertAcroFormFieldsToFieldInputs,
extractAcroFormFieldsFromPDF,
} from '@documenso/lib/server-only/pdf/acroform-fields';
import {
convertPlaceholdersToFieldInputs,
extractPdfPlaceholders,
@@ -10,8 +14,10 @@ import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-reques
import { prefixedId } from '@documenso/lib/universal/id';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { logger } from '@documenso/lib/utils/logger';
import { prisma } from '@documenso/prisma';
import type { Envelope, EnvelopeItem, Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
type UnsafeCreateEnvelopeItemsOptions = {
files: {
@@ -44,7 +50,8 @@ export const UNSAFE_createEnvelopeItems = async ({
}: UnsafeCreateEnvelopeItemsOptions) => {
const currentHighestOrderValue = envelope.envelopeItems[envelope.envelopeItems.length - 1]?.order ?? 1;
// For each file: normalize, extract & clean placeholders, then upload.
// For each file: extract AcroForm widgets, normalize, extract & clean
// placeholders, then upload.
const envelopeItemsToCreate = await Promise.all(
files.map(async ({ file, orderOverride, clientId }, index) => {
let buffer = Buffer.from(await file.arrayBuffer());
@@ -53,8 +60,55 @@ export const UNSAFE_createEnvelopeItems = async ({
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
// Run AcroForm extraction BEFORE normalizePdf — flattening destroys
// widget geometry, which we need to reuse as Documenso fields.
const acroFormExtraction = await extractAcroFormFieldsFromPDF(buffer, {
formValuesProvided: Boolean(envelope.formValues),
});
if (acroFormExtraction.skipReason) {
logger.info(
{
event: 'acroform-import.skip',
envelopeItemTitle: file.name,
reason: acroFormExtraction.skipReason,
},
'AcroForm extraction skipped',
);
}
if (acroFormExtraction.unsupported.length > 0) {
const byReason: Record<string, number> = {};
for (const entry of acroFormExtraction.unsupported) {
byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1;
}
logger.info(
{
event: 'acroform-import.unsupported',
envelopeItemTitle: file.name,
count: acroFormExtraction.unsupported.length,
byReason,
},
'AcroForm import skipped unsupported widgets',
);
}
if (acroFormExtraction.hasSignedSignature) {
logger.warn(
{
event: 'acroform-import.signed-pdf-no-flatten',
envelopeItemTitle: file.name,
},
'Signed AcroForm signature detected — skipping flatten to preserve signature',
);
}
const shouldFlatten = envelope.type !== 'TEMPLATE' && !acroFormExtraction.hasSignedSignature;
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
flattenForm: shouldFlatten,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -71,6 +125,7 @@ export const UNSAFE_createEnvelopeItems = async ({
clientId,
documentDataId: documentData.id,
placeholders,
acroFormFields: acroFormExtraction.fields,
order: orderOverride ?? currentHighestOrderValue + index + 1,
};
}),
@@ -158,6 +213,75 @@ export const UNSAFE_createEnvelopeItems = async ({
});
}
}
const pickFirstSignableRecipient = () => {
const signable = orderedRecipients.filter(
(r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER,
);
return signable[0] ?? null;
};
const firstSignableRecipient = pickFirstSignableRecipient();
if (firstSignableRecipient) {
for (const uploadedItem of envelopeItemsToCreate) {
if (!uploadedItem.acroFormFields || uploadedItem.acroFormFields.length === 0) {
continue;
}
const createdItem = createdItems.find((ci) => ci.documentDataId === uploadedItem.documentDataId);
if (!createdItem) {
continue;
}
const acroFormFieldsToCreate = convertAcroFormFieldsToFieldInputs(
uploadedItem.acroFormFields,
() => firstSignableRecipient,
createdItem.id,
);
if (acroFormFieldsToCreate.length === 0) {
continue;
}
const createdFields = await tx.field.createManyAndReturn({
data: acroFormFieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: createdItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
if (envelope.type === 'DOCUMENT') {
await tx.documentAuditLog.createMany({
data: createdFields.map((createdField) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: apiRequestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: firstSignableRecipient.email,
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
}),
),
});
}
}
}
}
return createdItems.map((item) => {
@@ -1,4 +1,6 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { AcroFormFieldImportInfo } from '@documenso/lib/server-only/pdf/acroform-fields';
import { convertAcroFormFieldsToFieldInputs } from '@documenso/lib/server-only/pdf/acroform-fields';
import type { PlaceholderInfo } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { convertPlaceholdersToFieldInputs } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
@@ -72,6 +74,7 @@ export type CreateEnvelopeOptions = {
documentDataId: string;
order?: number;
placeholders?: PlaceholderInfo[];
acroFormFields?: AcroFormFieldImportInfo[];
}[];
formValues?: TDocumentFormValues;
@@ -537,6 +540,136 @@ export const createEnvelope = async ({
}
}
// Create fields from imported AcroForm widgets (extracted at upload time).
// Runs after the placeholder branch so placeholder-created recipients are
// visible in `availableRecipientsForAcroForm`.
const itemsWithAcroFormFields = envelopeItems.filter(
(item) => item.acroFormFields && item.acroFormFields.length > 0,
);
if (itemsWithAcroFormFields.length > 0) {
let availableRecipientsForAcroForm = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true, role: true, signingOrder: true },
});
const pickFirstSignableRecipient = () => {
const signable = availableRecipientsForAcroForm.filter(
(r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER,
);
if (signable.length === 0) {
return null;
}
return [...signable].sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
})[0];
};
let firstSignableRecipient = pickFirstSignableRecipient();
if (!firstSignableRecipient) {
// No signable recipient yet — create a placeholder Recipient 1 SIGNER
// mirroring the placeholder branch's behavior.
const placeholderEmail = 'recipient.1@documenso.com';
const existingPlaceholder = availableRecipientsForAcroForm.find(
(r) => r.email.toLowerCase() === placeholderEmail,
);
if (!existingPlaceholder) {
await tx.recipient.create({
data: {
envelopeId: envelope.id,
email: placeholderEmail,
name: 'Recipient 1',
role: RecipientRole.SIGNER,
signingOrder: 1,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
});
}
// eslint-disable-next-line require-atomic-updates
availableRecipientsForAcroForm = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true, role: true, signingOrder: true },
});
firstSignableRecipient = pickFirstSignableRecipient();
}
if (!firstSignableRecipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Could not resolve a signable recipient for AcroForm import.',
});
}
const acroFormRecipient = firstSignableRecipient;
for (const item of itemsWithAcroFormFields) {
const envelopeItem = envelope.envelopeItems.find((ei) => ei.documentDataId === item.documentDataId);
if (!envelopeItem) {
continue;
}
const fieldsToCreate = convertAcroFormFieldsToFieldInputs(
item.acroFormFields ?? [],
() => acroFormRecipient,
envelopeItem.id,
);
if (fieldsToCreate.length === 0) {
continue;
}
const createdFields = await tx.field.createManyAndReturn({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
if (type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.createMany({
data: createdFields.map((createdField) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: acroFormRecipient.email,
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
}),
),
});
}
}
}
const createdEnvelope = await tx.envelope.findFirst({
where: {
id: envelope.id,
@@ -0,0 +1,321 @@
import fs from 'node:fs';
import path from 'node:path';
import { PDF } from '@libpdf/core';
import { FieldType } from '@prisma/client';
import { beforeAll, describe, expect, it } from 'vitest';
import { convertAcroFormFieldsToFieldInputs, extractAcroFormFieldsFromPDF } from './acroform-fields';
const ASSETS_DIR = path.resolve(__dirname, '../../../../assets');
const loadAsset = (name: string) => fs.readFileSync(path.join(ASSETS_DIR, name));
const buildPdfBuffer = async (rotation: 0 | 90 | 180 | 270 = 0) => {
const pdf = PDF.create();
pdf.addPage({ width: 612, height: 792, rotate: rotation });
pdf.addPage({ width: 612, height: 792, rotate: rotation });
const form = pdf.getOrCreateForm();
const [page1, page2] = pdf.getPages();
const customerName = form.createTextField('CustomerName');
page1.drawField(customerName, { x: 80, y: 620, width: 200, height: 24 });
const signedDate = form.createTextField('signed_date');
page1.drawField(signedDate, { x: 80, y: 560, width: 200, height: 24 });
const acceptTerms = form.createCheckbox('accept_terms', { onValue: 'Yes' });
page1.drawField(acceptTerms, { x: 80, y: 500, width: 18, height: 18 });
const country = form.createDropdown('country', {
options: ['USA', 'Canada', 'Germany'],
defaultValue: 'USA',
});
page2.drawField(country, { x: 80, y: 700, width: 200, height: 24 });
const payment = form.createRadioGroup('payment_method', {
options: ['Credit Card', 'PayPal', 'Bank Transfer'],
defaultValue: 'PayPal',
});
page2.drawField(payment, { x: 80, y: 640, width: 16, height: 16, option: 'Credit Card' });
page2.drawField(payment, { x: 80, y: 615, width: 16, height: 16, option: 'PayPal' });
page2.drawField(payment, { x: 80, y: 590, width: 16, height: 16, option: 'Bank Transfer' });
const initials = form.createTextField('initials');
page2.drawField(initials, { x: 80, y: 540, width: 60, height: 24 });
const email = form.createTextField('contact_email');
page2.drawField(email, { x: 160, y: 540, width: 220, height: 24 });
const qty = form.createTextField('item_qty', { maxLength: 4 });
page2.drawField(qty, { x: 400, y: 540, width: 60, height: 24 });
return Buffer.from(await pdf.save());
};
describe('extractAcroFormFieldsFromPDF', () => {
let baseBuffer: Buffer;
beforeAll(async () => {
baseBuffer = await buildPdfBuffer();
});
describe('non-AcroForm input', () => {
it('returns no-form skip reason for PDFs without an AcroForm', async () => {
const pdf = PDF.create();
pdf.addPage({ width: 612, height: 792 });
const emptyBuffer = Buffer.from(await pdf.save());
const result = await extractAcroFormFieldsFromPDF(emptyBuffer);
expect(result.fields).toEqual([]);
expect(result.unsupported).toEqual([]);
expect(result.hasSignedSignature).toBe(false);
expect(result.skipReason).toBe('no-form');
});
it('returns error skip reason without throwing for malformed PDFs', async () => {
const garbage = Buffer.from('not a pdf');
const result = await extractAcroFormFieldsFromPDF(garbage);
expect(result.fields).toEqual([]);
expect(result.skipReason).toBe('error');
});
});
describe('field type resolution', () => {
it('maps each supported AcroForm field type to its Documenso counterpart', async () => {
const result = await extractAcroFormFieldsFromPDF(baseBuffer);
expect(result.skipReason).toBeUndefined();
expect(result.hasSignedSignature).toBe(false);
expect(result.unsupported).toEqual([]);
const byName = new Map(result.fields.map((f) => [f.fieldName, f.fieldAndMeta.type]));
expect(byName.get('CustomerName')).toBe(FieldType.NAME);
expect(byName.get('signed_date')).toBe(FieldType.DATE);
expect(byName.get('accept_terms')).toBe(FieldType.CHECKBOX);
expect(byName.get('country')).toBe(FieldType.DROPDOWN);
expect(byName.get('payment_method')).toBe(FieldType.RADIO);
expect(byName.get('initials')).toBe(FieldType.INITIALS);
expect(byName.get('contact_email')).toBe(FieldType.EMAIL);
expect(byName.get('item_qty')).toBe(FieldType.NUMBER);
});
it('emits one Documenso RADIO field per widget (one field per option)', async () => {
const result = await extractAcroFormFieldsFromPDF(baseBuffer);
const radioFields = result.fields.filter((f) => f.fieldName === 'payment_method');
expect(radioFields).toHaveLength(3);
expect(radioFields.every((f) => f.fieldAndMeta.type === FieldType.RADIO)).toBe(true);
});
it('stamps source: "acroform" on every imported field meta', async () => {
const result = await extractAcroFormFieldsFromPDF(baseBuffer);
expect(result.fields.length).toBeGreaterThan(0);
for (const f of result.fields) {
expect(f.fieldAndMeta.fieldMeta?.source).toBe('acroform');
}
});
it('uses partialName as label when no /TU is set', async () => {
const result = await extractAcroFormFieldsFromPDF(baseBuffer);
const nameField = result.fields.find((f) => f.fieldName === 'CustomerName');
expect(nameField).toBeDefined();
expect(nameField?.fieldAndMeta.fieldMeta?.label).toBe('CustomerName');
});
});
describe('default values', () => {
it('copies AcroForm dropdown default into fieldMeta.defaultValue when formValues was not provided', async () => {
const result = await extractAcroFormFieldsFromPDF(baseBuffer);
const country = result.fields.find((f) => f.fieldName === 'country');
expect(country?.fieldAndMeta.type).toBe(FieldType.DROPDOWN);
if (country?.fieldAndMeta.type === FieldType.DROPDOWN) {
expect(country.fieldAndMeta.fieldMeta?.defaultValue).toBe('USA');
expect(country.fieldAndMeta.fieldMeta?.values?.map((v) => v.value)).toEqual(['USA', 'Canada', 'Germany']);
}
});
it('skips AcroForm defaults when formValuesProvided is true', async () => {
const result = await extractAcroFormFieldsFromPDF(baseBuffer, { formValuesProvided: true });
const country = result.fields.find((f) => f.fieldName === 'country');
expect(country?.fieldAndMeta.type).toBe(FieldType.DROPDOWN);
if (country?.fieldAndMeta.type === FieldType.DROPDOWN) {
expect(country.fieldAndMeta.fieldMeta?.defaultValue).toBe('');
}
});
it('marks the default radio option as checked when formValues was not provided', async () => {
const result = await extractAcroFormFieldsFromPDF(baseBuffer);
const radios = result.fields.filter((f) => f.fieldName === 'payment_method');
const checkedValues = radios.flatMap((r) => {
if (r.fieldAndMeta.type !== FieldType.RADIO) {
return [];
}
return (r.fieldAndMeta.fieldMeta?.values ?? []).filter((v) => v.checked).map((v) => v.value);
});
expect(checkedValues).toContain('PayPal');
});
});
describe('geometry', () => {
it('produces percentages relative to rendered page dimensions for non-rotated pages', async () => {
const result = await extractAcroFormFieldsFromPDF(baseBuffer);
const customerName = result.fields.find((f) => f.fieldName === 'CustomerName');
expect(customerName).toBeDefined();
if (customerName) {
// user (80, 620) with width 200, height 24 on a 612x792 page.
// Rendered top-left: (80, 792 - 620 - 24) = (80, 148).
const xPct = (customerName.x / customerName.pageWidth) * 100;
const yPct = (customerName.y / customerName.pageHeight) * 100;
const wPct = (customerName.width / customerName.pageWidth) * 100;
const hPct = (customerName.height / customerName.pageHeight) * 100;
expect(xPct).toBeCloseTo((80 / 612) * 100, 2);
expect(yPct).toBeCloseTo((148 / 792) * 100, 2);
expect(wPct).toBeCloseTo((200 / 612) * 100, 2);
expect(hPct).toBeCloseTo((24 / 792) * 100, 2);
}
});
it('handles 90-degree rotated pages with the inverse rotation transform', async () => {
const rotated = await buildPdfBuffer(90);
const result = await extractAcroFormFieldsFromPDF(rotated);
const customerName = result.fields.find((f) => f.fieldName === 'CustomerName');
expect(customerName).toBeDefined();
if (customerName) {
// Rendered page dims for /Rotate 90: width = mediaH (792), height = mediaW (612).
expect(customerName.pageWidth).toBeCloseTo(792, 0);
expect(customerName.pageHeight).toBeCloseTo(612, 0);
// R=90 transform: renderedX = yB = 620, renderedY = xL = 80.
const xPct = (customerName.x / customerName.pageWidth) * 100;
const yPct = (customerName.y / customerName.pageHeight) * 100;
expect(xPct).toBeCloseTo((620 / 792) * 100, 2);
expect(yPct).toBeCloseTo((80 / 612) * 100, 2);
}
});
it('handles 180-degree rotated pages', async () => {
const rotated = await buildPdfBuffer(180);
const result = await extractAcroFormFieldsFromPDF(rotated);
const customerName = result.fields.find((f) => f.fieldName === 'CustomerName');
expect(customerName).toBeDefined();
if (customerName) {
// R=180: renderedX = mediaW - xR = 612 - 280 = 332. renderedY = yB = 620.
const xPct = (customerName.x / customerName.pageWidth) * 100;
const yPct = (customerName.y / customerName.pageHeight) * 100;
expect(xPct).toBeCloseTo((332 / 612) * 100, 2);
expect(yPct).toBeCloseTo((620 / 792) * 100, 2);
}
});
it('handles 270-degree rotated pages', async () => {
const rotated = await buildPdfBuffer(270);
const result = await extractAcroFormFieldsFromPDF(rotated);
const customerName = result.fields.find((f) => f.fieldName === 'CustomerName');
expect(customerName).toBeDefined();
if (customerName) {
// R=270 page dims swap: pageW = mediaH = 792, pageH = mediaW = 612.
// renderedX = mediaH - yT = 792 - 644 = 148. renderedY = mediaW - xR = 612 - 280 = 332.
const xPct = (customerName.x / customerName.pageWidth) * 100;
const yPct = (customerName.y / customerName.pageHeight) * 100;
expect(xPct).toBeCloseTo((148 / 792) * 100, 2);
expect(yPct).toBeCloseTo((332 / 612) * 100, 2);
}
});
});
});
describe('convertAcroFormFieldsToFieldInputs', () => {
it('sorts by (page, y, x) before mapping recipients', async () => {
const buffer = await buildPdfBuffer();
const { fields } = await extractAcroFormFieldsFromPDF(buffer);
const recipient = { id: 42 };
const inputs = convertAcroFormFieldsToFieldInputs(fields, () => recipient, 'env_item_1');
expect(inputs).toHaveLength(fields.length);
// Sorted by page first.
const pages = inputs.map((i) => i.page);
const sortedPages = [...pages].sort((a, b) => a - b);
expect(pages).toEqual(sortedPages);
// Within a single page, positionY (top-left coords) should be ascending
// when rows are >2% apart.
for (let i = 1; i < inputs.length; i++) {
const prev = inputs[i - 1];
const curr = inputs[i];
if (prev.page !== curr.page) {
continue;
}
const yDelta = curr.positionY - prev.positionY;
// Either next row (yDelta > -2%) or same row (then x must be ascending).
if (Math.abs(yDelta) <= 2) {
expect(curr.positionX).toBeGreaterThanOrEqual(prev.positionX);
} else {
expect(yDelta).toBeGreaterThan(0);
}
}
});
it('passes recipientId through the resolver for every imported field', async () => {
const buffer = await buildPdfBuffer();
const { fields } = await extractAcroFormFieldsFromPDF(buffer);
const recipient = { id: 7 };
const inputs = convertAcroFormFieldsToFieldInputs(fields, () => recipient, 'env_item_xyz');
for (const input of inputs) {
expect(input.recipientId).toBe(recipient.id);
expect(input.envelopeItemId).toBe('env_item_xyz');
}
});
});
describe('extractAcroFormFieldsFromPDF — committed fixture', () => {
it('extracts the expected fields from assets/acroform-import-test.pdf', async () => {
const buffer = loadAsset('acroform-import-test.pdf');
const result = await extractAcroFormFieldsFromPDF(buffer);
expect(result.skipReason).toBeUndefined();
expect(result.unsupported).toEqual([]);
expect(result.fields.length).toBeGreaterThanOrEqual(8);
});
it('extracts the expected fields from rotated fixtures (90/180/270)', async () => {
for (const angle of [90, 180, 270]) {
const buffer = loadAsset(`acroform-import-rotated-${angle}.pdf`);
const result = await extractAcroFormFieldsFromPDF(buffer);
expect(result.skipReason).toBeUndefined();
expect(result.unsupported).toEqual([]);
expect(result.fields.length).toBeGreaterThanOrEqual(8);
}
});
});
@@ -0,0 +1,852 @@
import { PDF, type PDFForm, type PDFPage, type PdfDict, type PdfRef } from '@libpdf/core';
import { FieldType, type Recipient } from '@prisma/client';
import {
FIELD_CHECKBOX_META_DEFAULT_VALUES,
FIELD_DATE_META_DEFAULT_VALUES,
FIELD_DROPDOWN_META_DEFAULT_VALUES,
FIELD_EMAIL_META_DEFAULT_VALUES,
FIELD_INITIALS_META_DEFAULT_VALUES,
FIELD_NAME_META_DEFAULT_VALUES,
FIELD_NUMBER_META_DEFAULT_VALUES,
FIELD_RADIO_META_DEFAULT_VALUES,
FIELD_SIGNATURE_META_DEFAULT_VALUES,
FIELD_TEXT_META_DEFAULT_VALUES,
type TCheckboxFieldMeta,
type TDropdownFieldMeta,
type TFieldAndMeta,
type TNumberFieldMeta,
type TRadioFieldMeta,
type TTextFieldMeta,
ZEnvelopeFieldAndMetaSchema,
} from '../../types/field-meta';
import { logger } from '../../utils/logger';
import type { FieldToCreate } from './auto-place-fields';
/**
* Local shape for the widget annotations returned by @libpdf/core.
*
* The library exposes WidgetAnnotation as a class but does not re-export the
* type from its public surface. We duck-type the subset we actually read.
*/
type WidgetAnnotation = {
readonly rect: [number, number, number, number];
readonly width: number;
readonly height: number;
readonly pageRef: PdfRef | null;
isHidden(): boolean;
getOnValue(): string | null;
};
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
const MIN_HEIGHT_THRESHOLD = 0.01;
const DATE_NAME_PATTERN = /date|dob|birth/i;
const NUMBER_NAME_PATTERN = /amount|qty|count|number/i;
const EMAIL_NAME_PATTERN = /email|e[-_]?mail/i;
const NAME_NAME_PATTERN = /name/i;
const INITIALS_NAME_PATTERN = /initial/i;
const ROW_TOLERANCE_PERCENT = 2;
export type AcroFormUnsupportedReason =
| 'unsupported-type'
| 'hidden'
| 'off-page'
| 'zero-size'
| 'no-page-match'
| 'signed-signature'
| 'rotated-out-of-bounds';
export type AcroFormSkipReason = 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
export type AcroFormFieldImportInfo = {
source: 'acroform';
fieldName: string;
widgetIndex: number;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export type AcroFormUnsupportedFieldInfo = {
fieldName: string;
acroFormType: string;
reason: AcroFormUnsupportedReason;
};
export type AcroFormExtractionResult = {
fields: AcroFormFieldImportInfo[];
unsupported: AcroFormUnsupportedFieldInfo[];
/**
* True when a signed signature widget was found.
*
* Callers MUST set `flattenForm: false` for that envelope item so the signed
* PDF is not re-flattened (which would invalidate the signature).
*/
hasSignedSignature: boolean;
/**
* Set when extraction returned empty for a reason that should be surfaced in
* logs but not propagated to the user. Absent when extraction ran normally.
*/
skipReason?: AcroFormSkipReason;
};
type ResolvedGeometry = {
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
const EMPTY_RESULT = (skipReason?: AcroFormSkipReason): AcroFormExtractionResult => ({
fields: [],
unsupported: [],
hasSignedSignature: false,
skipReason,
});
const hasXfa = (form: PDFForm): boolean => {
// PDFForm doesn't expose the AcroForm dict directly. Inspect a known field's
// parent chain to walk up to the AcroForm dict via /Parent traversal.
const fields = form.getFields();
if (fields.length === 0) {
return false;
}
// Walk up to the AcroForm dict by reading the field dict's /Parent chain.
// The root field's parent is null but its containing dict is /AcroForm
// (which holds /XFA). We approximate by checking the first field's raw dict
// for an inherited /XFA reference via getInheritable would require internal
// access; instead, inspect the dict for any /XFA marker keys on the field's
// chain. In practice XFA dicts surface on the AcroForm root, not fields.
// Best-effort: look at PDFForm internals via a duck-typed dict lookup.
type AcroFormInternal = { _acroForm?: { dict?: PdfDict } };
const internal = form as unknown as AcroFormInternal;
const dict = internal._acroForm?.dict;
return Boolean(dict?.has('XFA'));
};
const isDateFieldByName = (name: string | null | undefined): boolean => {
return name ? DATE_NAME_PATTERN.test(name) : false;
};
const isNumberFieldByName = (name: string | null | undefined): boolean => {
return name ? NUMBER_NAME_PATTERN.test(name) : false;
};
const isEmailFieldByName = (name: string | null | undefined): boolean => {
return name ? EMAIL_NAME_PATTERN.test(name) : false;
};
const isNameFieldByName = (name: string | null | undefined): boolean => {
return name ? NAME_NAME_PATTERN.test(name) : false;
};
const isInitialsFieldByName = (name: string | null | undefined): boolean => {
return name ? INITIALS_NAME_PATTERN.test(name) : false;
};
/**
* Detect AcroForm format actions on a text field dictionary.
*
* Adobe attaches a JavaScript format action via /AA -> /F -> /JS. The script
* body references `AFDate_FormatEx` or `AFNumber_Format` depending on the
* intended format. We do a string contains check on the raw script to avoid
* pulling in a JS parser.
*/
const getTextFieldFormatHint = (fieldDict: PdfDict): 'date' | 'number' | null => {
try {
const aa = fieldDict.get('AA');
if (!aa || typeof aa !== 'object' || aa.type !== 'dict') {
return null;
}
const aaDict = aa as PdfDict;
const formatEntry = aaDict.get('F');
if (!formatEntry || typeof formatEntry !== 'object' || formatEntry.type !== 'dict') {
return null;
}
const formatDict = formatEntry as PdfDict;
const js = formatDict.get('JS');
if (!js) {
return null;
}
// The JS entry may be a string or a stream. Coerce via toString() —
// both PdfString and PdfStream expose a textual representation.
const script = typeof js === 'string' ? js : String(js);
if (script.includes('AFDate_FormatEx') || script.includes('AFDate_Format')) {
return 'date';
}
if (script.includes('AFNumber_Format')) {
return 'number';
}
return null;
} catch {
return null;
}
};
type FormFieldWithDict = {
name: string;
partialName: string;
alternateName: string | null;
isReadOnly(): boolean;
isRequired(): boolean;
acroField(): PdfDict;
getWidgets(): WidgetAnnotation[];
};
type ResolvedTextDocumentenoType =
| typeof FieldType.TEXT
| typeof FieldType.DATE
| typeof FieldType.NUMBER
| typeof FieldType.EMAIL
| typeof FieldType.NAME
| typeof FieldType.INITIALS;
const resolveTextSubtype = (
field: FormFieldWithDict,
): {
documentenoType: ResolvedTextDocumentenoType;
} => {
const candidateNames = [field.partialName, field.alternateName];
let formatHint: 'date' | 'number' | null = null;
try {
formatHint = getTextFieldFormatHint(field.acroField());
} catch {
formatHint = null;
}
if (formatHint === 'date' || candidateNames.some(isDateFieldByName)) {
return { documentenoType: FieldType.DATE };
}
let maxLen = Number.POSITIVE_INFINITY;
try {
const lenEntry = field.acroField().get('MaxLen');
if (lenEntry && typeof lenEntry === 'object' && 'value' in lenEntry && typeof lenEntry.value === 'number') {
maxLen = lenEntry.value;
}
} catch {
// Ignore — MaxLen is optional.
}
if (formatHint === 'number' || (maxLen <= 10 && candidateNames.some(isNumberFieldByName))) {
return { documentenoType: FieldType.NUMBER };
}
if (candidateNames.some(isEmailFieldByName)) {
return { documentenoType: FieldType.EMAIL };
}
if (candidateNames.some(isNameFieldByName)) {
return { documentenoType: FieldType.NAME };
}
if (candidateNames.some(isInitialsFieldByName)) {
return { documentenoType: FieldType.INITIALS };
}
return { documentenoType: FieldType.TEXT };
};
const pickLabel = (field: FormFieldWithDict): string | undefined => {
const label = field.alternateName ?? field.partialName;
return label && label.length > 0 ? label : undefined;
};
type RotationDegrees = 0 | 90 | 180 | 270;
type RawRect = { x1: number; y1: number; x2: number; y2: number };
const getRectFromWidget = (widget: WidgetAnnotation): RawRect | null => {
const rect = widget.rect;
if (!rect || rect.length !== 4) {
return null;
}
const [x1, y1, x2, y2] = rect;
if (![x1, y1, x2, y2].every((v) => Number.isFinite(v))) {
return null;
}
return { x1, y1, x2, y2 };
};
const resolveGeometry = (
widget: WidgetAnnotation,
pageIndex: number,
page: PDFPage,
): { geometry: ResolvedGeometry | null; reason: AcroFormUnsupportedReason | null } => {
const rect = getRectFromWidget(widget);
if (!rect) {
return { geometry: null, reason: 'zero-size' };
}
const xL = Math.min(rect.x1, rect.x2);
const xR = Math.max(rect.x1, rect.x2);
const yB = Math.min(rect.y1, rect.y2);
const yT = Math.max(rect.y1, rect.y2);
if (xR - xL <= 0 || yT - yB <= 0) {
return { geometry: null, reason: 'zero-size' };
}
const mediaBox = page.getMediaBox();
const mediaW = mediaBox.width;
const mediaH = mediaBox.height;
const rotation = page.rotation as RotationDegrees;
// PDFPage.width / .height return rotation-adjusted dimensions, which is what
// we want for percent-based positioning relative to the rendered page.
const renderedW = page.width;
const renderedH = page.height;
let renderedX: number;
let renderedY: number;
let renderedFieldW: number;
let renderedFieldH: number;
if (rotation === 90) {
renderedX = yB;
renderedY = xL;
renderedFieldW = yT - yB;
renderedFieldH = xR - xL;
} else if (rotation === 180) {
renderedX = mediaW - xR;
renderedY = yB;
renderedFieldW = xR - xL;
renderedFieldH = yT - yB;
} else if (rotation === 270) {
renderedX = mediaH - yT;
renderedY = mediaW - xR;
renderedFieldW = yT - yB;
renderedFieldH = xR - xL;
} else {
renderedX = xL;
renderedY = mediaH - yT;
renderedFieldW = xR - xL;
renderedFieldH = yT - yB;
}
// Out-of-bounds: skip if the entire rect is outside the rendered page bounds.
const left = renderedX;
const right = renderedX + renderedFieldW;
const top = renderedY;
const bottom = renderedY + renderedFieldH;
if (right <= 0 || left >= renderedW || bottom <= 0 || top >= renderedH) {
return { geometry: null, reason: 'off-page' };
}
// Partial out-of-bounds: clamp.
const clampedLeft = Math.max(0, Math.min(left, renderedW));
const clampedRight = Math.max(0, Math.min(right, renderedW));
const clampedTop = Math.max(0, Math.min(top, renderedH));
const clampedBottom = Math.max(0, Math.min(bottom, renderedH));
const clampedW = clampedRight - clampedLeft;
const clampedH = clampedBottom - clampedTop;
if (clampedW <= 0 || clampedH <= 0) {
return { geometry: null, reason: 'off-page' };
}
return {
geometry: {
page: pageIndex + 1,
x: clampedLeft,
y: clampedTop,
width: clampedW,
height: clampedH,
pageWidth: renderedW,
pageHeight: renderedH,
},
reason: null,
};
};
const buildSignatureFieldAndMeta = (field: FormFieldWithDict): TFieldAndMeta => {
return ZEnvelopeFieldAndMetaSchema.parse({
type: FieldType.SIGNATURE,
fieldMeta: {
...FIELD_SIGNATURE_META_DEFAULT_VALUES,
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: 'acroform' as const,
},
});
};
const buildTextFieldAndMeta = (
field: FormFieldWithDict,
documentenoType: ResolvedTextDocumentenoType,
defaultText: string | undefined,
): TFieldAndMeta => {
const label = pickLabel(field);
const required = field.isRequired() || undefined;
const readOnly = field.isReadOnly() || undefined;
if (documentenoType === FieldType.NUMBER) {
const fieldMeta: TNumberFieldMeta = {
...FIELD_NUMBER_META_DEFAULT_VALUES,
label: label ?? FIELD_NUMBER_META_DEFAULT_VALUES.label,
required,
readOnly,
source: 'acroform' as const,
value: defaultText && defaultText.length > 0 ? defaultText : undefined,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: documentenoType, fieldMeta });
}
if (documentenoType === FieldType.TEXT) {
const fieldMeta: TTextFieldMeta = {
...FIELD_TEXT_META_DEFAULT_VALUES,
label: label ?? FIELD_TEXT_META_DEFAULT_VALUES.label,
required,
readOnly,
source: 'acroform' as const,
text: defaultText && defaultText.length > 0 ? defaultText : FIELD_TEXT_META_DEFAULT_VALUES.text,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: documentenoType, fieldMeta });
}
if (documentenoType === FieldType.DATE) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documentenoType,
fieldMeta: {
...FIELD_DATE_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: 'acroform' as const,
},
});
}
if (documentenoType === FieldType.EMAIL) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documentenoType,
fieldMeta: {
...FIELD_EMAIL_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: 'acroform' as const,
},
});
}
if (documentenoType === FieldType.NAME) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documentenoType,
fieldMeta: {
...FIELD_NAME_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: 'acroform' as const,
},
});
}
return ZEnvelopeFieldAndMetaSchema.parse({
type: documentenoType,
fieldMeta: {
...FIELD_INITIALS_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: 'acroform' as const,
},
});
};
const buildCheckboxFieldAndMeta = (
field: FormFieldWithDict,
onValue: string | undefined,
isChecked: boolean,
): TFieldAndMeta => {
const required = field.isRequired();
const value = onValue && onValue.length > 0 ? onValue : 'Yes';
const fieldMeta: TCheckboxFieldMeta = {
...FIELD_CHECKBOX_META_DEFAULT_VALUES,
label: pickLabel(field) ?? FIELD_CHECKBOX_META_DEFAULT_VALUES.label,
required: required || undefined,
readOnly: field.isReadOnly() || undefined,
source: 'acroform' as const,
values: [{ id: 1, checked: isChecked, value }],
validationRule: required ? 'at-least' : '',
validationLength: required ? 1 : 0,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.CHECKBOX, fieldMeta });
};
const buildRadioFieldAndMeta = (
field: FormFieldWithDict,
options: string[],
selectedValue: string | null,
widgetOnValue: string | null,
): TFieldAndMeta => {
const values =
options.length > 0
? options.map((value, index) => ({
id: index + 1,
checked: selectedValue !== null && value === selectedValue,
value,
}))
: [
{
id: 1,
checked: widgetOnValue !== null && widgetOnValue === selectedValue,
value: widgetOnValue ?? '',
},
];
const fieldMeta: TRadioFieldMeta = {
...FIELD_RADIO_META_DEFAULT_VALUES,
label: pickLabel(field) ?? '',
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: 'acroform' as const,
values,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.RADIO, fieldMeta });
};
const buildDropdownFieldAndMeta = (
field: FormFieldWithDict,
options: string[],
defaultValue: string | undefined,
): TFieldAndMeta => {
const fieldMeta: TDropdownFieldMeta = {
...FIELD_DROPDOWN_META_DEFAULT_VALUES,
label: pickLabel(field) ?? '',
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: 'acroform' as const,
values: options.length > 0 ? options.map((value) => ({ value })) : FIELD_DROPDOWN_META_DEFAULT_VALUES.values,
defaultValue: defaultValue ?? '',
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.DROPDOWN, fieldMeta });
};
type WidgetWithPage = { widget: WidgetAnnotation; pageIndex: number; page: PDFPage };
const resolveWidgetPages = (
widgets: WidgetAnnotation[],
pageByRef: Map<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 };
};
/**
* Options for {@link extractAcroFormFieldsFromPDF}.
*/
export type ExtractAcroFormOptions = {
/**
* When true, `insertFormValuesInPdf` already ran for this buffer. The
* extractor will not copy AcroForm default values into `fieldMeta` to
* avoid duplicating values that are already baked into the flattened PDF.
*/
formValuesProvided?: boolean;
};
/**
* Extract AcroForm fields from a PDF and convert them to Documenso field
* imports.
*
* Runs before flattening so widget geometry is still present in the buffer.
* Returns an empty result for non-AcroForm PDFs, encrypted PDFs, XFA hybrids,
* and on any internal error (with `skipReason` set so callers can log).
*/
export const extractAcroFormFieldsFromPDF = async (
pdf: Buffer,
options: ExtractAcroFormOptions = {},
): Promise<AcroFormExtractionResult> => {
try {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
if (pdfDoc.isEncrypted) {
return EMPTY_RESULT('encrypted');
}
const form = pdfDoc.getForm();
if (!form) {
return EMPTY_RESULT('no-form');
}
if (hasXfa(form)) {
return EMPTY_RESULT('xfa-hybrid');
}
const pages = pdfDoc.getPages();
const pageByRef = new Map<PdfRef, { index: number; page: PDFPage }>();
pages.forEach((page, index) => {
pageByRef.set(page.ref, { index, page });
});
const fields: AcroFormFieldImportInfo[] = [];
const unsupported: AcroFormUnsupportedFieldInfo[] = [];
let hasSignedSignature = false;
for (const field of form.getFields()) {
const acroFormType = field.type;
if (
acroFormType === 'listbox' ||
acroFormType === 'button' ||
acroFormType === 'unknown' ||
acroFormType === 'non-terminal'
) {
unsupported.push({
fieldName: field.name,
acroFormType,
reason: 'unsupported-type',
});
continue;
}
// Signed signature widgets are skipped entirely and the caller is asked
// to keep the form intact (no flatten) so the signature stays valid.
if (acroFormType === 'signature') {
type SignatureFieldDuck = FormFieldWithDict & { isSigned(): boolean };
const sigField = field as unknown as SignatureFieldDuck;
if (typeof sigField.isSigned === 'function' && sigField.isSigned()) {
hasSignedSignature = true;
unsupported.push({
fieldName: field.name,
acroFormType,
reason: 'signed-signature',
});
continue;
}
}
const formField = field as unknown as FormFieldWithDict;
const widgets = formField.getWidgets();
const { matched, unmatched } = resolveWidgetPages(widgets, pageByRef);
for (const widget of unmatched) {
unsupported.push({
fieldName: field.name,
acroFormType,
reason: 'no-page-match',
});
// Reference widget so it isn't tree-shaken in dev tooling; harmless at runtime.
void widget;
}
let widgetCounter = 0;
for (const { widget, pageIndex, page } of matched) {
if (widget.isHidden()) {
unsupported.push({
fieldName: field.name,
acroFormType,
reason: 'hidden',
});
continue;
}
const { geometry, reason } = resolveGeometry(widget, pageIndex, page);
if (!geometry) {
unsupported.push({
fieldName: field.name,
acroFormType,
reason: reason ?? 'zero-size',
});
continue;
}
let fieldAndMeta: TFieldAndMeta;
const usePdfDefaults = !options.formValuesProvided;
if (acroFormType === 'signature') {
fieldAndMeta = buildSignatureFieldAndMeta(formField);
} else if (acroFormType === 'text') {
type TextFieldDuck = FormFieldWithDict & {
getValue(): string;
getDefaultValue(): string;
};
const textField = field as unknown as TextFieldDuck;
const { documentenoType } = resolveTextSubtype(formField);
const defaultText = usePdfDefaults ? textField.getValue?.() || textField.getDefaultValue?.() || '' : '';
fieldAndMeta = buildTextFieldAndMeta(formField, documentenoType, defaultText);
} else if (acroFormType === 'checkbox') {
type CheckboxFieldDuck = FormFieldWithDict & {
isChecked(): boolean;
getOnValue(): string;
};
const checkbox = field as unknown as CheckboxFieldDuck;
const onValue = widget.getOnValue() ?? checkbox.getOnValue?.();
const checked = usePdfDefaults ? (checkbox.isChecked?.() ?? false) : false;
fieldAndMeta = buildCheckboxFieldAndMeta(formField, onValue ?? undefined, checked);
} else if (acroFormType === 'radio') {
type RadioFieldDuck = FormFieldWithDict & {
getOptions(): string[];
getValue(): string | null;
};
const radio = field as unknown as RadioFieldDuck;
const selectedValue = usePdfDefaults ? (radio.getValue?.() ?? null) : null;
fieldAndMeta = buildRadioFieldAndMeta(
formField,
radio.getOptions?.() ?? [],
selectedValue,
widget.getOnValue(),
);
} else if (acroFormType === 'dropdown') {
type DropdownFieldDuck = FormFieldWithDict & {
getOptions(): Array<{ value: string; display: string }>;
getValue(): string;
getDefaultValue(): string;
};
const dropdown = field as unknown as DropdownFieldDuck;
const rawOptions = dropdown.getOptions?.() ?? [];
const optionValues = rawOptions.map((opt) => opt.value);
const currentSelection = usePdfDefaults ? dropdown.getValue?.() || dropdown.getDefaultValue?.() || '' : '';
fieldAndMeta = buildDropdownFieldAndMeta(formField, optionValues, currentSelection || undefined);
} else {
unsupported.push({
fieldName: field.name,
acroFormType,
reason: 'unsupported-type',
});
continue;
}
fields.push({
source: 'acroform',
fieldName: field.name,
widgetIndex: widgetCounter,
fieldAndMeta,
page: geometry.page,
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
pageWidth: geometry.pageWidth,
pageHeight: geometry.pageHeight,
});
widgetCounter += 1;
}
}
return {
fields,
unsupported,
hasSignedSignature,
};
} catch (err) {
logger.error({ event: 'acroform-import.error', err }, 'AcroForm extraction threw');
return EMPTY_RESULT('error');
}
};
const sortFieldsForCreate = (fields: AcroFormFieldImportInfo[]): AcroFormFieldImportInfo[] => {
return [...fields].sort((a, b) => {
if (a.page !== b.page) {
return a.page - b.page;
}
const aRowPercent = (a.y / a.pageHeight) * 100;
const bRowPercent = (b.y / b.pageHeight) * 100;
if (Math.abs(aRowPercent - bRowPercent) > ROW_TOLERANCE_PERCENT) {
return aRowPercent - bRowPercent;
}
return a.x - b.x;
});
};
/**
* Convert pre-extracted AcroForm fields to field creation inputs.
*
* Pure data transform — converts points to percentages and resolves the
* recipient via the provided callback. No DB calls.
*/
export const convertAcroFormFieldsToFieldInputs = (
fields: AcroFormFieldImportInfo[],
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[] => {
const sorted = sortFieldsForCreate(fields);
return sorted.map((f) => {
const xPercent = (f.x / f.pageWidth) * 100;
const yPercent = (f.y / f.pageHeight) * 100;
const widthPercent = (f.width / f.pageWidth) * 100;
const heightPercent = (f.height / f.pageHeight) * 100;
const finalHeightPercent = heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
const recipient = recipientResolver(f.fieldName);
return {
...f.fieldAndMeta,
envelopeItemId,
recipientId: recipient.id,
page: f.page,
positionX: xPercent,
positionY: yPercent,
width: widthPercent,
height: finalHeightPercent,
};
});
};
+1
View File
@@ -72,6 +72,7 @@ export const ZBaseFieldMeta = z.object({
readOnly: z.boolean().optional(),
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
overflow: ZFieldOverflowMode.optional(),
source: z.enum(['acroform']).optional(),
});
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
@@ -2,6 +2,7 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { convertToPdf } from '@documenso/lib/server-only/document-conversion';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { extractAcroFormFieldsFromPDF } from '@documenso/lib/server-only/pdf/acroform-fields';
import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -106,7 +107,10 @@ export const createEnvelopeRouteCaller = async ({
});
}
// For each file: convert to PDF if needed, normalize, extract & clean placeholders, then upload.
// For each file: convert to PDF if needed, extract AcroForm widgets,
// normalize (which flattens the form unless we detected a signed signature
// and unless this is a template upload), extract & clean placeholders,
// then upload.
const envelopeItems = await Promise.all(
files.map(async (file) => {
let pdf = await convertToPdf(file, logger);
@@ -119,8 +123,55 @@ export const createEnvelopeRouteCaller = async ({
});
}
// Run AcroForm extraction BEFORE normalizePdf — flattening destroys
// widget geometry, which we need to reuse as Documenso fields.
const acroFormExtraction = await extractAcroFormFieldsFromPDF(pdf, {
formValuesProvided: Boolean(formValues),
});
if (acroFormExtraction.skipReason) {
logger?.info(
{
event: 'acroform-import.skip',
envelopeItemTitle: file.name,
reason: acroFormExtraction.skipReason,
},
'AcroForm extraction skipped',
);
}
if (acroFormExtraction.unsupported.length > 0) {
const byReason: Record<string, number> = {};
for (const entry of acroFormExtraction.unsupported) {
byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1;
}
logger?.info(
{
event: 'acroform-import.unsupported',
envelopeItemTitle: file.name,
count: acroFormExtraction.unsupported.length,
byReason,
},
'AcroForm import skipped unsupported widgets',
);
}
if (acroFormExtraction.hasSignedSignature) {
logger?.warn(
{
event: 'acroform-import.signed-pdf-no-flatten',
envelopeItemTitle: file.name,
},
'Signed AcroForm signature detected — skipping flatten to preserve signature',
);
}
const shouldFlatten = type !== EnvelopeType.TEMPLATE && !acroFormExtraction.hasSignedSignature;
const normalized = await normalizePdf(pdf, {
flattenForm: type !== EnvelopeType.TEMPLATE,
flattenForm: shouldFlatten,
});
// Todo: Embeds - Might need to add this for client-side embeds in the future.
@@ -136,6 +187,7 @@ export const createEnvelopeRouteCaller = async ({
title: file.name,
documentDataId: documentData.id,
placeholders,
acroFormFields: acroFormExtraction.fields,
};
}),
);