diff --git a/.agents/plans/loud-orange-leaf-acroform-field-detection-and-reuse.md b/.agents/plans/loud-orange-leaf-acroform-field-detection-and-reuse.md index e1ccb255d..532c25669 100644 --- a/.agents/plans/loud-orange-leaf-acroform-field-detection-and-reuse.md +++ b/.agents/plans/loud-orange-leaf-acroform-field-detection-and-reuse.md @@ -43,7 +43,7 @@ In scope: server-side extraction at upload time for v2 envelopes (both new envel | AcroForm type | Documenso field | Rule | | --- | --- | --- | | `signature` (unsigned) | `SIGNATURE` | Import. | -| `signature` (signed — `acroField().has('V')` is true) | — | **Skip.** Log `logger.warn({ event: 'acroform-import.signed-signature', envelopeItemTitle, fieldName })`. Also downgrade `flattenForm` to `false` for that envelope item (do not re-flatten a signed PDF). | +| `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. | @@ -52,14 +52,20 @@ In scope: server-side extraction at upload time for v2 envelopes (both new envel ### Text-field heuristic (resolved in order — first match wins) -1. **DATE** if `acroField()` carries an additional-actions date format (`/AA` → `/F` → `S = JavaScript` referencing `AFDate_FormatEx`) **or** name/alternateName matches `/date|dob|birth_date|signed_date/i`. -2. **NUMBER** if `acroField()` carries an `AFNumber_Format` action **or** (`/MaxLen <= 10` AND name matches `/amount|qty|count|number|num\b/i`). -3. **EMAIL** if name/alternateName matches `/\bemail\b|e[-_]?mail/i`. -4. **NAME** if name/alternateName matches `/\bname\b|full_?name|first_?name|last_?name|fname\b|lname\b/i`. -5. **INITIALS** if name/alternateName matches `/initial(s)?\b|\binit\b/i`. -6. Else **TEXT**. +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. -All regexes are case-insensitive and run against `partialName` then `alternateName`. False positives are reviewable in the editor; false negatives fall through to TEXT (always safe). +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 @@ -121,7 +127,7 @@ Reuse the placeholder convention (top-left percentages, see `auto-place-fields.t 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 = top`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`. + - `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. @@ -360,7 +366,7 @@ 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 (`/V` present) → `unsupported` with `reason: 'signed-signature'` AND `hasSignedSignature: true`. +- 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`) diff --git a/packages/lib/server-only/envelope/create-envelope.ts b/packages/lib/server-only/envelope/create-envelope.ts index 6474cfc44..5c17f0784 100644 --- a/packages/lib/server-only/envelope/create-envelope.ts +++ b/packages/lib/server-only/envelope/create-envelope.ts @@ -10,6 +10,7 @@ import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-log import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { nanoid, prefixedId } from '@documenso/lib/universal/id'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; +import { logger } from '@documenso/lib/utils/logger'; import { prisma } from '@documenso/prisma'; import type { DocumentMeta, DocumentVisibility, TemplateType } from '@prisma/client'; import { @@ -609,63 +610,67 @@ export const createEnvelope = async ({ } 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) => ({ + logger.warn( + { + event: 'acroform-import.no-signable-recipient', 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, - })), - }); + }, + 'AcroForm import skipped — no signable recipient available', + ); + } else { + const acroFormRecipient = firstSignableRecipient; - 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, - }, - }), - ), + 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, + }, + }), + ), + }); + } } } } diff --git a/packages/lib/server-only/pdf/acroform-fields.test.ts b/packages/lib/server-only/pdf/acroform-fields.test.ts index 231c7b0f5..f46c78558 100644 --- a/packages/lib/server-only/pdf/acroform-fields.test.ts +++ b/packages/lib/server-only/pdf/acroform-fields.test.ts @@ -1,9 +1,9 @@ import fs from 'node:fs'; import path from 'node:path'; -import { PDF } from '@libpdf/core'; +import { AnnotationFlags, PDF, PdfDict, PdfName, PdfNumber, PdfString } from '@libpdf/core'; import { FieldType } from '@prisma/client'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { convertAcroFormFieldsToFieldInputs, extractAcroFormFieldsFromPDF } from './acroform-fields'; @@ -51,6 +51,47 @@ const buildPdfBuffer = async (rotation: 0 | 90 | 180 | 270 = 0) => { const qty = form.createTextField('item_qty', { maxLength: 4 }); page2.drawField(qty, { x: 400, y: 540, width: 60, height: 24 }); + // Format-action precedence: name matches /name/i (would resolve to NAME) but + // AcroForm carries an `AFNumber_Format` action — the action MUST win. + const customerNameNumberField = form.createTextField('customer_name_amount'); + page2.drawField(customerNameNumberField, { x: 80, y: 480, width: 120, height: 24 }); + customerNameNumberField.acroField().set( + 'AA', + PdfDict.of({ + F: PdfDict.of({ + S: PdfName.of('JavaScript'), + JS: PdfString.fromString('AFNumber_Format(2, 0, 0, 0, "$", true)'), + }), + }), + ); + + // Required CHECKBOX: /Ff bit 2 (REQUIRED) set via raw dict. + const requiredTerms = form.createCheckbox('required_terms', { onValue: 'Accepted' }); + page2.drawField(requiredTerms, { x: 220, y: 480, width: 18, height: 18 }); + requiredTerms.acroField().set('Ff', PdfNumber.of(2)); + + // /TU (alternateName) label fallback: partialName is `shipping_addr` but + // /TU carries a human label that MUST be preferred over partialName. + const shippingAddress = form.createTextField('shipping_addr'); + page2.drawField(shippingAddress, { x: 280, y: 480, width: 220, height: 24 }); + shippingAddress.acroField().set('TU', PdfString.fromString('Shipping address')); + + // Hidden widget: should land in `unsupported` with reason 'hidden' rather + // than producing a field. WidgetAnnotation has no public setFlag, so we + // write the annotation /F entry directly (bit 2 = Hidden). + const hiddenField = form.createTextField('hidden_field'); + page2.drawField(hiddenField, { x: 80, y: 440, width: 100, height: 20 }); + const hiddenWidgets = hiddenField.getWidgets() as Array<{ dict: PdfDict }>; + if (hiddenWidgets[0]) { + hiddenWidgets[0].dict.set('F', PdfNumber.of(AnnotationFlags.Hidden)); + } + + // Off-page widget: positioned beyond the right edge of a 612pt-wide page so + // the extractor reports `off-page`. Drawn AFTER all others so the file is + // intentionally malformed. + const offPageField = form.createTextField('off_page_field'); + page2.drawField(offPageField, { x: 700, y: 440, width: 60, height: 20 }); + return Buffer.from(await pdf.save()); }; @@ -91,7 +132,8 @@ describe('extractAcroFormFieldsFromPDF', () => { expect(result.skipReason).toBeUndefined(); expect(result.hasSignedSignature).toBe(false); - expect(result.unsupported).toEqual([]); + // Two intentionally-bad widgets in the fixture: hidden and off-page. + expect(result.unsupported.map((u) => u.reason).sort()).toEqual(['hidden', 'off-page']); const byName = new Map(result.fields.map((f) => [f.fieldName, f.fieldAndMeta.type])); @@ -103,6 +145,9 @@ describe('extractAcroFormFieldsFromPDF', () => { expect(byName.get('initials')).toBe(FieldType.INITIALS); expect(byName.get('contact_email')).toBe(FieldType.EMAIL); expect(byName.get('item_qty')).toBe(FieldType.NUMBER); + // Hidden and off-page widgets must not appear as fields. + expect(byName.has('hidden_field')).toBe(false); + expect(byName.has('off_page_field')).toBe(false); }); it('emits one Documenso RADIO field per widget (one field per option)', async () => { @@ -130,6 +175,35 @@ describe('extractAcroFormFieldsFromPDF', () => { expect(nameField).toBeDefined(); expect(nameField?.fieldAndMeta.fieldMeta?.label).toBe('CustomerName'); }); + + it('prefers AcroForm /TU (alternateName) over partialName for the label', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer); + const shippingField = result.fields.find((f) => f.fieldName === 'shipping_addr'); + + expect(shippingField).toBeDefined(); + expect(shippingField?.fieldAndMeta.fieldMeta?.label).toBe('Shipping address'); + }); + + it('lets AcroForm format actions override the name heuristic (P1.1)', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer); + const numberField = result.fields.find((f) => f.fieldName === 'customer_name_amount'); + + // Without the format action, /name/i would steer this to NAME. The + // `AFNumber_Format` action MUST win. + expect(numberField?.fieldAndMeta.type).toBe(FieldType.NUMBER); + }); + + it('maps required CHECKBOX (/Ff bit 2) to validationRule "at-least" + length 1', async () => { + const result = await extractAcroFormFieldsFromPDF(baseBuffer); + const required = result.fields.find((f) => f.fieldName === 'required_terms'); + + expect(required?.fieldAndMeta.type).toBe(FieldType.CHECKBOX); + if (required?.fieldAndMeta.type === FieldType.CHECKBOX) { + expect(required.fieldAndMeta.fieldMeta?.required).toBe(true); + expect(required.fieldAndMeta.fieldMeta?.validationRule).toBe('at-least'); + expect(required.fieldAndMeta.fieldMeta?.validationLength).toBe(1); + } + }); }); describe('default values', () => { @@ -319,3 +393,187 @@ describe('extractAcroFormFieldsFromPDF — committed fixture', () => { } }); }); + +/** + * Stub-based scenarios exercise paths that {@link buildPdfBuffer} cannot easily + * produce (signed signatures, encrypted docs, XFA hybrids, listboxes, widgets + * with mismatched page refs). Each stub mirrors the methods the extractor + * actually calls — keeping the contract narrow so a future refactor of the + * extractor surfaces here as a loud test failure rather than a silent miss. + */ +describe('extractAcroFormFieldsFromPDF — mocked scenarios', () => { + const emptyAcroFormDict = { has: () => false }; + const emptyCatalogDict = { + getDict: () => emptyAcroFormDict, + }; + const emptyContext = { + catalog: { getDict: () => emptyCatalogDict }, + resolve: () => null, + }; + + const buildFieldStub = (overrides: Record) => ({ + name: 'stub_field', + partialName: 'stub_field', + alternateName: null, + isReadOnly: () => false, + isRequired: () => false, + acroField: () => ({ + get: () => undefined, + getDict: () => undefined, + getNumber: () => undefined, + has: () => false, + set: () => undefined, + }), + getWidgets: () => [], + ...overrides, + }); + + it('returns skipReason "encrypted" when PDF.isEncrypted is true (P2.6)', async () => { + vi.spyOn(PDF, 'load').mockResolvedValueOnce({ isEncrypted: true } as unknown as PDF); + + const result = await extractAcroFormFieldsFromPDF(Buffer.from('stub')); + + expect(result.skipReason).toBe('encrypted'); + expect(result.fields).toEqual([]); + expect(result.hasSignedSignature).toBe(false); + }); + + it('returns skipReason "xfa-hybrid" when /AcroForm has /XFA (P2.1)', async () => { + const xfaCatalogDict = { + getDict: (key: string) => (key === 'AcroForm' ? { has: (k: string) => k === 'XFA' } : undefined), + }; + + vi.spyOn(PDF, 'load').mockResolvedValueOnce({ + isEncrypted: false, + context: { catalog: { getDict: () => xfaCatalogDict }, resolve: () => null }, + getForm: () => ({ getFields: () => [] }), + getPages: () => [], + } as unknown as PDF); + + const result = await extractAcroFormFieldsFromPDF(Buffer.from('stub')); + + expect(result.skipReason).toBe('xfa-hybrid'); + expect(result.fields).toEqual([]); + }); + + it('skips signed signature widgets and reports hasSignedSignature: true (P1.2)', async () => { + const signedField = buildFieldStub({ + type: 'signature', + name: 'ExistingSignature', + partialName: 'ExistingSignature', + isSigned: () => true, + }); + + vi.spyOn(PDF, 'load').mockResolvedValueOnce({ + isEncrypted: false, + context: emptyContext, + getForm: () => ({ getFields: () => [signedField] }), + getPages: () => [], + } as unknown as PDF); + + const result = await extractAcroFormFieldsFromPDF(Buffer.from('stub')); + + expect(result.hasSignedSignature).toBe(true); + expect(result.fields).toEqual([]); + expect(result.unsupported.find((u) => u.reason === 'signed-signature')).toMatchObject({ + fieldName: 'ExistingSignature', + acroFormType: 'signature', + }); + }); + + it('imports unsigned signature widgets normally (P1.2 negative control)', async () => { + const pageRef = { objectNumber: 1, generation: 0 } as unknown as PdfDict; + const widget = { + rect: [100, 600, 300, 640] as [number, number, number, number], + pageRef, + isHidden: () => false, + getOnValue: () => null, + }; + const unsignedField = buildFieldStub({ + type: 'signature', + name: 'NewSignature', + partialName: 'NewSignature', + isSigned: () => false, + 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: emptyContext, + getForm: () => ({ getFields: () => [unsignedField] }), + getPages: () => [page], + } as unknown as PDF); + + const result = await extractAcroFormFieldsFromPDF(Buffer.from('stub')); + + expect(result.hasSignedSignature).toBe(false); + expect(result.fields).toHaveLength(1); + expect(result.fields[0]?.fieldAndMeta.type).toBe(FieldType.SIGNATURE); + }); + + it('reports listbox fields as unsupported-type (P2.6)', async () => { + const listboxField = buildFieldStub({ + type: 'listbox', + name: 'colors', + partialName: 'colors', + }); + + vi.spyOn(PDF, 'load').mockResolvedValueOnce({ + isEncrypted: false, + context: emptyContext, + getForm: () => ({ getFields: () => [listboxField] }), + getPages: () => [], + } as unknown as PDF); + + const result = await extractAcroFormFieldsFromPDF(Buffer.from('stub')); + + expect(result.fields).toEqual([]); + expect(result.unsupported).toHaveLength(1); + expect(result.unsupported[0]?.reason).toBe('unsupported-type'); + expect(result.unsupported[0]?.acroFormType).toBe('listbox'); + }); + + it('reports widgets with unknown pageRef as no-page-match (P2.6)', async () => { + const realPageRef = { objectNumber: 1, generation: 0 } as unknown as PdfDict; + const orphanPageRef = { objectNumber: 99, generation: 0 } as unknown as PdfDict; + const orphanField = buildFieldStub({ + type: 'text', + name: 'orphan_text', + partialName: 'orphan_text', + getWidgets: () => [ + { + rect: [100, 600, 200, 620] as [number, number, number, number], + pageRef: orphanPageRef, + isHidden: () => false, + getOnValue: () => null, + }, + ], + }); + const page = { + ref: realPageRef, + width: 612, + height: 792, + rotation: 0, + getMediaBox: () => ({ x: 0, y: 0, width: 612, height: 792 }), + }; + + vi.spyOn(PDF, 'load').mockResolvedValueOnce({ + isEncrypted: false, + context: emptyContext, + getForm: () => ({ getFields: () => [orphanField] }), + getPages: () => [page], + } as unknown as PDF); + + const result = await extractAcroFormFieldsFromPDF(Buffer.from('stub')); + + expect(result.fields).toEqual([]); + expect(result.unsupported.map((u) => u.reason)).toContain('no-page-match'); + }); +}); diff --git a/packages/lib/server-only/pdf/acroform-fields.ts b/packages/lib/server-only/pdf/acroform-fields.ts index 0941998a2..3171dacc5 100644 --- a/packages/lib/server-only/pdf/acroform-fields.ts +++ b/packages/lib/server-only/pdf/acroform-fields.ts @@ -1,4 +1,12 @@ -import { PDF, type PDFForm, type PDFPage, type PdfDict, type PdfRef } from '@libpdf/core'; +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, @@ -37,6 +45,19 @@ type WidgetAnnotation = { 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; @@ -113,26 +134,23 @@ const EMPTY_RESULT = (skipReason?: AcroFormSkipReason): AcroFormExtractionResult 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) { +/** + * 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; } - - // 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 => { @@ -158,36 +176,42 @@ const isInitialsFieldByName = (name: string | null | undefined): boolean => { /** * Detect AcroForm format actions on a text field dictionary. * - * Adobe attaches a JavaScript format action via /AA -> /F -> /JS. The script + * 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. + * 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): 'date' | 'number' | null => { +const getTextFieldFormatHint = (fieldDict: PdfDict, resolver: RefResolver): 'date' | 'number' | null => { try { - const aa = fieldDict.get('AA'); + const aaDict = fieldDict.getDict('AA', resolver); - if (!aa || typeof aa !== 'object' || aa.type !== 'dict') { + if (!aaDict) { return null; } - const aaDict = aa as PdfDict; - const formatEntry = aaDict.get('F'); + const formatDict = aaDict.getDict('F', resolver); - if (!formatEntry || typeof formatEntry !== 'object' || formatEntry.type !== 'dict') { + if (!formatDict) { return null; } - const formatDict = formatEntry as PdfDict; - const js = formatDict.get('JS'); + const js = formatDict.get('JS', resolver); - if (!js) { + if (!js || typeof js !== 'object') { 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); + 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'; @@ -223,6 +247,7 @@ type ResolvedTextDocumentenoType = const resolveTextSubtype = ( field: FormFieldWithDict, + resolver: RefResolver, ): { documentenoType: ResolvedTextDocumentenoType; } => { @@ -231,28 +256,38 @@ const resolveTextSubtype = ( let formatHint: 'date' | 'number' | null = null; try { - formatHint = getTextFieldFormatHint(field.acroField()); + formatHint = getTextFieldFormatHint(field.acroField(), resolver); } catch { formatHint = null; } - if (formatHint === 'date' || candidateNames.some(isDateFieldByName)) { + // 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 { documentenoType: FieldType.DATE }; } + if (formatHint === 'number') { + return { documentenoType: FieldType.NUMBER }; + } + let maxLen = Number.POSITIVE_INFINITY; try { - const lenEntry = field.acroField().get('MaxLen'); + const lenEntry = field.acroField().getNumber('MaxLen', resolver); - if (lenEntry && typeof lenEntry === 'object' && 'value' in lenEntry && typeof lenEntry.value === 'number') { + if (lenEntry) { maxLen = lenEntry.value; } } catch { // Ignore — MaxLen is optional. } - if (formatHint === 'number' || (maxLen <= 10 && candidateNames.some(isNumberFieldByName))) { + if (candidateNames.some(isDateFieldByName)) { + return { documentenoType: FieldType.DATE }; + } + + if (maxLen <= 10 && candidateNames.some(isNumberFieldByName)) { return { documentenoType: FieldType.NUMBER }; } @@ -620,16 +655,18 @@ export const extractAcroFormFieldsFromPDF = async ( return EMPTY_RESULT('encrypted'); } + const resolver = makeResolver(pdfDoc); + + if (hasXfa(pdfDoc, resolver)) { + return EMPTY_RESULT('xfa-hybrid'); + } + 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(); @@ -723,7 +760,7 @@ export const extractAcroFormFieldsFromPDF = async ( getDefaultValue(): string; }; const textField = field as unknown as TextFieldDuck; - const { documentenoType } = resolveTextSubtype(formField); + const { documentenoType } = resolveTextSubtype(formField, resolver); const defaultText = usePdfDefaults ? textField.getValue?.() || textField.getDefaultValue?.() || '' : ''; fieldAndMeta = buildTextFieldAndMeta(formField, documentenoType, defaultText); } else if (acroFormType === 'checkbox') {