mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 01:45:08 +10:00
fix(pdf): repair acroform extractor heuristic + xfa detection + skip-don't-throw
Eight findings from PR review of feat/acroform-field-import, all verified
against actual source in plan mode before fixing.
P1.1 — getTextFieldFormatHint never produced a hint in practice. PdfDict.get()
returns PdfRef for indirect entries (Adobe almost always emits /AA and /F as
indirect), and String(js) returned "[object Object]" because PdfString and
PdfStream don't override toString. Now we thread a RefResolver via
PDF.context.resolve through every dict lookup, use PdfDict.getDict(key,
resolver) so refs are auto-deref'd, and decode JS bodies via asString() (for
strings) or TextDecoder + getDecodedData() (for streams). The MaxLen probe had
the same bug — also fixed via getNumber(key, resolver). Branch ordering is
restructured so format actions take precedence over every name token, not
just within their own type bucket.
P1.2 — Signed-signature path had no test. Added a stub-based mock test that
drives extractAcroFormFieldsFromPDF against a SignatureField whose isSigned()
returns true and asserts hasSignedSignature: true, fields: [], and an
unsupported entry with reason: 'signed-signature'. Mirrored with a negative
control where isSigned() returns false.
P2.1 — hasXfa reached into PDFForm._acroForm (private readonly) via a cast.
Replaced with public catalog access: pdf.context.catalog.getDict() →
getDict('AcroForm', resolver) → has('XFA'). Removed the
fields.length === 0 short-circuit so pure-XFA docs with no /Fields surface
as xfa-hybrid instead of falling through.
P2.2 — When no signable recipient resolved, the AcroForm branch threw
AppError(NOT_FOUND) inside prisma.$transaction and tore down the entire
envelope creation. Replaced with logger.warn + early skip from the AcroForm
branch only. Matches UNSAFE_createEnvelopeItems' silent-skip behaviour.
P2.6 — Coverage gaps closed with 9 new test cases: encrypted (mock), xfa-
hybrid (mock), signed signature (mock + negative control), listbox unsupported
(mock), no-page-match (mock), format-action precedence (extends fixture),
/TU label fallback (extends fixture), required CHECKBOX (extends fixture
with Ff bit 2), hidden + off-page widgets (extends fixture).
P2.3 / P2.4 / P2.5 — Plan doc updated to match shipped implementation: rot=180
y formula corrected from `top` to `bottom`, heuristic regexes documented as
the lenient substring patterns actually shipped (with explicit false-positive
acknowledgements), signed-signature detection mechanism updated from raw /V
probe to SignatureField.isSigned().
Verification: 26/26 unit tests pass (was 17, +9). lib + trpc + remix
typecheck clean (lib retains 5 pre-existing unrelated errors). biome clean.
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>) => ({
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PdfRef, { index: number; page: PDFPage }>();
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user