mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 23:07:13 +10:00
fix(pdf): stabilize AcroForm imports
This commit is contained in:
@@ -31,7 +31,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { 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 { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
@@ -78,7 +78,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
|
||||
const { envelope, editorFields, navigateToStep, editorConfig, flushAutosave, syncEnvelope } =
|
||||
useCurrentEnvelopeEditor();
|
||||
|
||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||
|
||||
@@ -86,12 +87,34 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
const [acroFormHasFieldsByItemRevision, setAcroFormHasFieldsByItemRevision] = useState<Record<string, boolean>>({});
|
||||
|
||||
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
|
||||
? acroFormHasFieldsByItemRevision[currentEnvelopeItemRevision] === true
|
||||
: false;
|
||||
|
||||
const onAcroFormDetected = useCallback(
|
||||
(hasFields: boolean) => {
|
||||
if (!currentEnvelopeItemRevision) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAcroFormHasFieldsByItemRevision((prev) =>
|
||||
prev[currentEnvelopeItemRevision] === hasFields ? prev : { ...prev, [currentEnvelopeItemRevision]: hasFields },
|
||||
);
|
||||
},
|
||||
[currentEnvelopeItemRevision],
|
||||
);
|
||||
|
||||
const envelopeItemPermissions = useMemo(
|
||||
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
|
||||
[envelope, envelope.recipients],
|
||||
@@ -160,6 +183,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
|
||||
const onImportFromPdfClick = async () => {
|
||||
try {
|
||||
await flushAutosave();
|
||||
const result = await importFieldsFromPdf({ envelopeId: envelope.id });
|
||||
|
||||
if (result.fieldsCreated === 0) {
|
||||
@@ -172,7 +196,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
await revalidate();
|
||||
await syncEnvelope();
|
||||
|
||||
toast({
|
||||
title: _(msg`Fields imported`),
|
||||
@@ -255,6 +279,7 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
||||
scrollParentRef={scrollableContainerRef}
|
||||
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
|
||||
onAcroFormDetected={onAcroFormDetected}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-32">
|
||||
@@ -313,23 +338,6 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => void onImportFromPdfClick()}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT || isImportingFieldsFromPdf}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only import fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FormInputIcon className="mr-2 -ml-1 h-4 w-4" />
|
||||
{isImportingFieldsFromPdf ? <Trans>Importing...</Trans> : <Trans>Import from PDF form</Trans>}
|
||||
</Button>
|
||||
|
||||
{editorConfig.fields?.allowAIDetection && (
|
||||
<>
|
||||
<Button
|
||||
@@ -364,6 +372,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>
|
||||
|
||||
{/* Field details section. */}
|
||||
|
||||
@@ -16,7 +16,12 @@ export type EnvelopePdfViewerProps = {
|
||||
errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null;
|
||||
} & Omit<PDFViewerProps, 'data'>;
|
||||
|
||||
export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: EnvelopePdfViewerProps) => {
|
||||
export const EnvelopePdfViewer = ({
|
||||
errorMessage,
|
||||
className,
|
||||
onAcroFormDetected,
|
||||
...props
|
||||
}: EnvelopePdfViewerProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const $el = useRef<HTMLDivElement>(null);
|
||||
@@ -46,10 +51,11 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
|
||||
|
||||
return (
|
||||
<PDFViewerLazy
|
||||
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
|
||||
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}-${currentEnvelopeItem.documentDataId}`}
|
||||
{...props}
|
||||
className={cn('h-full w-full max-w-[800px]', className)}
|
||||
data={currentEnvelopeItem.data}
|
||||
onAcroFormDetected={onAcroFormDetected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ export type PDFViewerProps = {
|
||||
scrollParentRef: ScrollTarget;
|
||||
|
||||
onDocumentLoad?: () => void;
|
||||
onAcroFormDetected?: (hasFields: boolean) => void;
|
||||
|
||||
/**
|
||||
* Additional component to render next to the image, such as a Konva canvas
|
||||
@@ -63,6 +64,7 @@ export default function PDFViewer({
|
||||
data,
|
||||
scrollParentRef,
|
||||
onDocumentLoad,
|
||||
onAcroFormDetected,
|
||||
customPageRenderer,
|
||||
...props
|
||||
}: PDFViewerProps) {
|
||||
@@ -124,6 +126,20 @@ export default function PDFViewer({
|
||||
// eslint-disable-next-line require-atomic-updates
|
||||
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
|
||||
const pages = await pMap(Array.from({ length: loadedPdf.numPages }), async (_, pageIndex) => {
|
||||
const page = await loadedPdf.getPage(pageIndex + 1);
|
||||
@@ -168,7 +184,7 @@ export default function PDFViewer({
|
||||
pdfRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [data]);
|
||||
}, [data, onAcroFormDetected]);
|
||||
|
||||
// Notify when document is loaded
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user