diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index 9d6087061..9e30c77d1 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -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(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>({}); + 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} /> ) : (
@@ -313,23 +338,6 @@ export const EnvelopeEditorFieldsPage = () => { selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null} /> - - {editorConfig.fields?.allowAIDetection && ( <> + )} {/* Field details section. */} diff --git a/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx index ac38c4272..e73b2665b 100644 --- a/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx +++ b/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx @@ -16,7 +16,12 @@ export type EnvelopePdfViewerProps = { errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null; } & Omit; -export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: EnvelopePdfViewerProps) => { +export const EnvelopePdfViewer = ({ + errorMessage, + className, + onAcroFormDetected, + ...props +}: EnvelopePdfViewerProps) => { const { t } = useLingui(); const $el = useRef(null); @@ -46,10 +51,11 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop return ( ); }; diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx index 56c5308d6..67e1740ae 100644 --- a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx +++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx @@ -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(() => { diff --git a/packages/app-tests/e2e/scenarios/acroform-import.spec.ts b/packages/app-tests/e2e/scenarios/acroform-import.spec.ts index 0f739451e..d6858831c 100644 --- a/packages/app-tests/e2e/scenarios/acroform-import.spec.ts +++ b/packages/app-tests/e2e/scenarios/acroform-import.spec.ts @@ -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({ diff --git a/packages/lib/client-only/providers/envelope-render-provider.tsx b/packages/lib/client-only/providers/envelope-render-provider.tsx index b72484ef3..d340dc4a1 100644 --- a/packages/lib/client-only/providers/envelope-render-provider.tsx +++ b/packages/lib/client-only/providers/envelope-render-provider.tsx @@ -43,6 +43,7 @@ type EnvelopeRenderItem = { title: string; order: number; envelopeId: string; + documentDataId: string; /** * The PDF data to render. diff --git a/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts b/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts index 108250aaf..22b48a2c8 100644 --- a/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts +++ b/packages/lib/server-only/envelope-item/replace-envelope-item-pdf.ts @@ -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); diff --git a/packages/lib/server-only/pdf/acroform-fields.test.ts b/packages/lib/server-only/pdf/acroform-fields.test.ts index f46c78558..d186f8be0 100644 --- a/packages/lib/server-only/pdf/acroform-fields.test.ts +++ b/packages/lib/server-only/pdf/acroform-fields.test.ts @@ -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', diff --git a/packages/lib/server-only/pdf/acroform-fields.ts b/packages/lib/server-only/pdf/acroform-fields.ts index 06fe80ba2..8f88a0754 100644 --- a/packages/lib/server-only/pdf/acroform-fields.ts +++ b/packages/lib/server-only/pdf/acroform-fields.ts @@ -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 (