fix(pdf): stabilize AcroForm imports

This commit is contained in:
ephraimduncan
2026-05-27 00:16:03 +00:00
parent 824117d47e
commit 874243700f
8 changed files with 192 additions and 38 deletions
@@ -2,6 +2,7 @@ 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 { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
@@ -107,6 +108,67 @@ test.describe('AcroForm Import', () => {
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('replacement preserves widgets in the stored PDF for later import', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const response = await uploadAcroFormEnvelope({
request,
token,
payload: {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document',
recipients: [
{
email: 'signer@example.com',
name: 'Signer',
role: RecipientRole.SIGNER,
},
],
},
});
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: {
requestMetadata: {},
source: 'apiV1',
auth: 'api',
},
});
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 { user, team } = await seedUser();
const { token } = await createApiToken({
@@ -43,6 +43,7 @@ type EnvelopeRenderItem = {
title: string;
order: number;
envelopeId: string;
documentDataId: string;
/**
* The PDF data to render.
@@ -80,8 +80,10 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
buffer = await insertFormValuesInPdf({ pdf: buffer, formValues: envelope.formValues });
}
// Preserve interactive AcroForm widgets so they can be imported as
// Documenso fields on demand via the editor's "Import from PDF" button.
const normalized = await normalizePdf(buffer, {
flattenForm: envelope.type !== 'TEMPLATE',
flattenForm: false,
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -168,12 +168,12 @@ describe('extractAcroFormFieldsFromPDF', () => {
}
});
it('uses partialName as label when no /TU is set', async () => {
it('does not use /T partialName as a 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');
expect(nameField?.fieldAndMeta.fieldMeta?.label).toBeUndefined();
});
it('prefers AcroForm /TU (alternateName) over partialName for the label', async () => {
@@ -438,7 +438,7 @@ describe('extractAcroFormFieldsFromPDF — mocked scenarios', () => {
expect(result.hasSignedSignature).toBe(false);
});
it('returns skipReason "xfa-hybrid" when /AcroForm has /XFA (P2.1)', async () => {
it('returns skipReason "xfa-hybrid" when /AcroForm has /XFA and no AcroForm fields (P2.1)', async () => {
const xfaCatalogDict = {
getDict: (key: string) => (key === 'AcroForm' ? { has: (k: string) => k === 'XFA' } : undefined),
};
@@ -456,6 +456,48 @@ describe('extractAcroFormFieldsFromPDF — mocked scenarios', () => {
expect(result.fields).toEqual([]);
});
it('imports AcroForm widgets even when /AcroForm also has /XFA', async () => {
const xfaCatalogDict = {
getDict: (key: string) => (key === 'AcroForm' ? { has: (k: string) => k === 'XFA' } : undefined),
};
const pageRef = { objectNumber: 1, generation: 0 } as unknown as PdfDict;
const widget = {
rect: [100, 600, 300, 624] as [number, number, number, number],
pageRef,
isHidden: () => false,
getOnValue: () => null,
};
const field = buildFieldStub({
type: 'text',
name: 'topmostSubform[0].Page1[0].f1_1[0]',
partialName: 'f1_1',
getValue: () => '',
getDefaultValue: () => '',
getWidgets: () => [widget],
});
const page = {
ref: pageRef,
width: 612,
height: 792,
rotation: 0,
getMediaBox: () => ({ x: 0, y: 0, width: 612, height: 792 }),
};
vi.spyOn(PDF, 'load').mockResolvedValueOnce({
isEncrypted: false,
context: { catalog: { getDict: () => xfaCatalogDict }, resolve: () => null },
getForm: () => ({ getFields: () => [field] }),
getPages: () => [page],
} as unknown as PDF);
const result = await extractAcroFormFieldsFromPDF(Buffer.from('stub'));
expect(result.skipReason).toBeUndefined();
expect(result.fields).toHaveLength(1);
expect(result.fields[0]?.fieldAndMeta.type).toBe(FieldType.TEXT);
expect(result.fields[0]?.fieldAndMeta.fieldMeta?.label).toBe('');
});
it('skips signed signature widgets and reports hasSignedSignature: true (P1.2)', async () => {
const signedField = buildFieldStub({
type: 'signature',
+14 -11
View File
@@ -307,9 +307,8 @@ const resolveTextSubtype = (
};
const pickLabel = (field: FormFieldWithDict): string | undefined => {
const label = field.alternateName ?? field.partialName;
return label && label.length > 0 ? label : undefined;
// /TU is the human-facing tooltip/label; /T is the internal field identifier.
return field.alternateName || undefined;
};
type RotationDegrees = 0 | 90 | 180 | 270;
@@ -641,8 +640,9 @@ export type ExtractAcroFormOptions = {
* 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).
* 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,
@@ -657,14 +657,17 @@ export const extractAcroFormFieldsFromPDF = async (
const resolver = makeResolver(pdfDoc);
if (hasXfa(pdfDoc, resolver)) {
return EMPTY_RESULT('xfa-hybrid');
}
const hasXfaForm = hasXfa(pdfDoc, resolver);
const form = pdfDoc.getForm();
if (!form) {
return EMPTY_RESULT('no-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();
@@ -678,7 +681,7 @@ export const extractAcroFormFieldsFromPDF = async (
const unsupported: AcroFormUnsupportedFieldInfo[] = [];
let hasSignedSignature = false;
for (const field of form.getFields()) {
for (const field of formFields) {
const acroFormType = field.type;
if (