mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 20:02:47 +01:00
415 lines
12 KiB
TypeScript
415 lines
12 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
PDFArray,
|
|
PDFDocument,
|
|
PDFName,
|
|
PDFRadioGroup,
|
|
PDFRef,
|
|
PDFString,
|
|
PDFTextField,
|
|
PDFWidgetAnnotation,
|
|
rgb,
|
|
} from 'pdf-lib';
|
|
import { extractExistingFields } from '../js/logic/form-creator-extraction.ts';
|
|
import type { ExtractedFieldLike } from '@/types';
|
|
|
|
const TEST_EXTRACTION_METRICS = {
|
|
pdfViewerOffset: { x: 0, y: 0 },
|
|
pdfViewerScale: 1,
|
|
};
|
|
|
|
function extractFieldsForTest(pdfDoc: PDFDocument): ExtractedFieldLike[] {
|
|
const result = extractExistingFields({
|
|
pdfDoc,
|
|
fieldCounterStart: 0,
|
|
metrics: TEST_EXTRACTION_METRICS,
|
|
});
|
|
|
|
return result.fields
|
|
.filter(
|
|
(
|
|
field
|
|
): field is typeof field & {
|
|
type: 'text' | 'radio';
|
|
} => field.type === 'text' || field.type === 'radio'
|
|
)
|
|
.map((field) => ({
|
|
type: field.type,
|
|
name: field.name,
|
|
pageIndex: field.pageIndex,
|
|
x: field.x,
|
|
y: field.y,
|
|
width: field.width,
|
|
height: field.height,
|
|
tooltip: field.tooltip,
|
|
required: field.required,
|
|
readOnly: field.readOnly,
|
|
checked: field.checked,
|
|
exportValue: field.exportValue,
|
|
groupName: field.groupName,
|
|
}));
|
|
}
|
|
|
|
function getWidgetRef(
|
|
widget: PDFWidgetAnnotation,
|
|
pdfDoc: PDFDocument
|
|
): PDFRef {
|
|
const ref = pdfDoc.context.getObjectRef(widget.dict);
|
|
if (!ref) {
|
|
throw new Error('Expected widget dictionary to be registered in context');
|
|
}
|
|
return ref;
|
|
}
|
|
|
|
function getPageAnnotsArray(pdfDoc: PDFDocument, pageIndex: number): PDFArray {
|
|
const page = pdfDoc.getPages()[pageIndex];
|
|
const annots = page.node.get(PDFName.of('Annots'));
|
|
const annotsArray = pdfDoc.context.lookupMaybe(annots, PDFArray);
|
|
if (!annotsArray) {
|
|
throw new Error(`Expected page ${pageIndex} to have an /Annots array`);
|
|
}
|
|
return annotsArray;
|
|
}
|
|
|
|
function removeAnnotRefFromPage(
|
|
pdfDoc: PDFDocument,
|
|
pageIndex: number,
|
|
targetRef: PDFRef
|
|
): void {
|
|
const annotsArray = getPageAnnotsArray(pdfDoc, pageIndex);
|
|
const kept = annotsArray.asArray().filter((object) => object !== targetRef);
|
|
|
|
const replacement = pdfDoc.context.obj(kept) as PDFArray;
|
|
pdfDoc.getPages()[pageIndex].node.set(PDFName.of('Annots'), replacement);
|
|
}
|
|
|
|
function addAnnotRefToPage(
|
|
pdfDoc: PDFDocument,
|
|
pageIndex: number,
|
|
annotRef: PDFRef
|
|
): void {
|
|
const annotsArray = getPageAnnotsArray(pdfDoc, pageIndex);
|
|
annotsArray.push(annotRef);
|
|
}
|
|
|
|
async function buildTwoPageDropdownPdf(): Promise<{
|
|
pdfDoc: PDFDocument;
|
|
}> {
|
|
const pdfDoc = await PDFDocument.create();
|
|
const page1 = pdfDoc.addPage([600, 800]);
|
|
const page2 = pdfDoc.addPage([600, 800]);
|
|
const form = pdfDoc.getForm();
|
|
|
|
const dropdown = form.createDropdown('statusSelect');
|
|
dropdown.addToPage(page2, {
|
|
x: 110,
|
|
y: 300,
|
|
width: 220,
|
|
height: 40,
|
|
borderColor: rgb(0, 0, 0),
|
|
});
|
|
dropdown.setOptions(['Draft', 'Final']);
|
|
dropdown.select('Final');
|
|
dropdown.acroField
|
|
.getWidgets()[0]
|
|
.dict.set(PDFName.of('TU'), PDFString.of('Status tooltip'));
|
|
|
|
const page1Field = form.createTextField('page1CompanionField');
|
|
page1Field.addToPage(page1, {
|
|
x: 80,
|
|
y: 580,
|
|
width: 180,
|
|
height: 50,
|
|
borderColor: rgb(0, 0, 0),
|
|
});
|
|
|
|
return { pdfDoc };
|
|
}
|
|
|
|
async function buildTwoPageTextFieldPdf(): Promise<{
|
|
pdfDoc: PDFDocument;
|
|
page1Field: PDFTextField;
|
|
page2Field: PDFTextField;
|
|
}> {
|
|
const pdfDoc = await PDFDocument.create();
|
|
const page1 = pdfDoc.addPage([600, 800]);
|
|
const page2 = pdfDoc.addPage([600, 800]);
|
|
const form = pdfDoc.getForm();
|
|
|
|
const page1Field = form.createTextField('page1TextField');
|
|
page1Field.addToPage(page1, {
|
|
x: 80,
|
|
y: 580,
|
|
width: 320,
|
|
height: 80,
|
|
borderColor: rgb(0, 0, 0),
|
|
});
|
|
page1Field.setText('Page 1');
|
|
page1Field.enableRequired();
|
|
page1Field.acroField
|
|
.getWidgets()[0]
|
|
.dict.set(PDFName.of('TU'), PDFString.of('First page tooltip'));
|
|
|
|
const page2Field = form.createTextField('page2TextField');
|
|
page2Field.addToPage(page2, {
|
|
x: 90,
|
|
y: 360,
|
|
width: 360,
|
|
height: 120,
|
|
borderColor: rgb(0, 0, 0),
|
|
});
|
|
page2Field.setText('Page 2');
|
|
page2Field.enableReadOnly();
|
|
page2Field.acroField
|
|
.getWidgets()[0]
|
|
.dict.set(PDFName.of('TU'), PDFString.of('Second page tooltip'));
|
|
|
|
return { pdfDoc, page1Field, page2Field };
|
|
}
|
|
|
|
async function buildTwoPageRadioPdf(): Promise<{
|
|
pdfDoc: PDFDocument;
|
|
radioGroup: PDFRadioGroup;
|
|
}> {
|
|
const pdfDoc = await PDFDocument.create();
|
|
const page1 = pdfDoc.addPage([600, 800]);
|
|
const page2 = pdfDoc.addPage([600, 800]);
|
|
const form = pdfDoc.getForm();
|
|
|
|
const radioGroup = form.createRadioGroup('statusGroup');
|
|
radioGroup.enableRequired();
|
|
|
|
radioGroup.addOptionToPage('draft', page1, {
|
|
x: 120,
|
|
y: 620,
|
|
width: 20,
|
|
height: 20,
|
|
borderColor: rgb(0, 0, 0),
|
|
});
|
|
|
|
radioGroup.addOptionToPage('final', page2, {
|
|
x: 180,
|
|
y: 420,
|
|
width: 20,
|
|
height: 20,
|
|
borderColor: rgb(0, 0, 0),
|
|
});
|
|
|
|
radioGroup.select('final');
|
|
|
|
const widgets = radioGroup.acroField.getWidgets();
|
|
widgets[0].dict.set(PDFName.of('TU'), PDFString.of('Draft option'));
|
|
widgets[1].dict.set(PDFName.of('TU'), PDFString.of('Final option'));
|
|
|
|
return { pdfDoc, radioGroup };
|
|
}
|
|
|
|
describe('form creator extraction regression', () => {
|
|
it('keeps text fields on their original pages when widgets have no /P entry', async () => {
|
|
const { pdfDoc, page1Field, page2Field } = await buildTwoPageTextFieldPdf();
|
|
|
|
const page1Widget = page1Field.acroField.getWidgets()[0];
|
|
const page2Widget = page2Field.acroField.getWidgets()[0];
|
|
|
|
page1Widget.dict.delete(PDFName.of('P'));
|
|
page2Widget.dict.delete(PDFName.of('P'));
|
|
|
|
const extracted = extractFieldsForTest(pdfDoc);
|
|
|
|
expect(extracted).toHaveLength(2);
|
|
|
|
const first = extracted.find((field) => field.name === 'page1TextField');
|
|
const second = extracted.find((field) => field.name === 'page2TextField');
|
|
|
|
expect(first).toMatchObject({
|
|
type: 'text',
|
|
pageIndex: 0,
|
|
tooltip: 'First page tooltip',
|
|
required: true,
|
|
readOnly: false,
|
|
});
|
|
|
|
expect(second).toMatchObject({
|
|
type: 'text',
|
|
pageIndex: 1,
|
|
tooltip: 'Second page tooltip',
|
|
required: false,
|
|
readOnly: true,
|
|
});
|
|
});
|
|
|
|
it('prefers the explicit widget /P page reference when present', async () => {
|
|
const { pdfDoc, page1Field } = await buildTwoPageTextFieldPdf();
|
|
|
|
const widget = page1Field.acroField.getWidgets()[0];
|
|
const page2Ref = pdfDoc.getPages()[1].ref;
|
|
|
|
widget.setP(page2Ref);
|
|
|
|
const extracted = extractFieldsForTest(pdfDoc);
|
|
const field = extracted.find((entry) => entry.name === 'page1TextField');
|
|
|
|
expect(field).toBeDefined();
|
|
expect(field?.pageIndex).toBe(1);
|
|
});
|
|
|
|
it('extracts radio widgets across different pages when /P is missing', async () => {
|
|
const { pdfDoc, radioGroup } = await buildTwoPageRadioPdf();
|
|
|
|
const widgets = radioGroup.acroField.getWidgets();
|
|
widgets.forEach((widget) => {
|
|
widget.dict.delete(PDFName.of('P'));
|
|
});
|
|
|
|
const extracted = extractFieldsForTest(pdfDoc)
|
|
.filter((field) => field.name === 'statusGroup')
|
|
.sort((a, b) => a.pageIndex - b.pageIndex);
|
|
|
|
expect(extracted).toHaveLength(2);
|
|
expect(extracted[0]).toMatchObject({
|
|
type: 'radio',
|
|
pageIndex: 0,
|
|
exportValue: 'draft',
|
|
tooltip: 'Draft option',
|
|
groupName: 'statusGroup',
|
|
required: true,
|
|
});
|
|
expect(extracted[1]).toMatchObject({
|
|
type: 'radio',
|
|
pageIndex: 1,
|
|
exportValue: 'final',
|
|
tooltip: 'Final option',
|
|
groupName: 'statusGroup',
|
|
required: true,
|
|
});
|
|
});
|
|
|
|
it('skips fields whose widgets cannot be resolved to any page', async () => {
|
|
const { pdfDoc, page1Field, page2Field } = await buildTwoPageTextFieldPdf();
|
|
|
|
const page2Widget = page2Field.acroField.getWidgets()[0];
|
|
const page2WidgetRef = getWidgetRef(page2Widget, pdfDoc);
|
|
|
|
page2Widget.dict.delete(PDFName.of('P'));
|
|
removeAnnotRefFromPage(pdfDoc, 1, page2WidgetRef);
|
|
|
|
const extracted = extractFieldsForTest(pdfDoc);
|
|
|
|
expect(extracted.map((field) => field.name)).toEqual(['page1TextField']);
|
|
expect(
|
|
extracted.find((field) => field.name === 'page2TextField')
|
|
).toBeUndefined();
|
|
|
|
addAnnotRefToPage(pdfDoc, 1, page2WidgetRef);
|
|
const extractedAfterRestore = extractFieldsForTest(pdfDoc);
|
|
expect(extractedAfterRestore.map((field) => field.name).sort()).toEqual([
|
|
'page1TextField',
|
|
'page2TextField',
|
|
]);
|
|
});
|
|
|
|
it('matches the same real-world failure mode as the reported sample structure', async () => {
|
|
const { pdfDoc, page1Field, page2Field } = await buildTwoPageTextFieldPdf();
|
|
|
|
const page1Widget = page1Field.acroField.getWidgets()[0];
|
|
const page2Widget = page2Field.acroField.getWidgets()[0];
|
|
|
|
page1Widget.dict.delete(PDFName.of('P'));
|
|
page2Widget.dict.delete(PDFName.of('P'));
|
|
|
|
const page1Annots = getPageAnnotsArray(pdfDoc, 0);
|
|
const page2Annots = getPageAnnotsArray(pdfDoc, 1);
|
|
|
|
expect(page1Annots.asArray()).toHaveLength(1);
|
|
expect(page2Annots.asArray()).toHaveLength(1);
|
|
|
|
const extracted = extractFieldsForTest(pdfDoc);
|
|
const pageMap = Object.fromEntries(
|
|
extracted.map((field) => [field.name, field.pageIndex])
|
|
);
|
|
|
|
expect(pageMap).toEqual({
|
|
page1TextField: 0,
|
|
page2TextField: 1,
|
|
});
|
|
});
|
|
|
|
it('extracts non-radio field metadata through the shared helper path', async () => {
|
|
const { pdfDoc } = await buildTwoPageDropdownPdf();
|
|
const dropdownWidget = pdfDoc
|
|
.getForm()
|
|
.getDropdown('statusSelect')
|
|
.acroField.getWidgets()[0];
|
|
|
|
dropdownWidget.dict.delete(PDFName.of('P'));
|
|
|
|
const extracted = extractExistingFields({
|
|
pdfDoc,
|
|
fieldCounterStart: 7,
|
|
metrics: TEST_EXTRACTION_METRICS,
|
|
});
|
|
|
|
const dropdownField = extracted.fields.find(
|
|
(field) => field.name === 'statusSelect'
|
|
);
|
|
|
|
expect(extracted.nextFieldCounter).toBe(9);
|
|
expect(extracted.extractedFieldNames.has('statusSelect')).toBe(true);
|
|
expect(dropdownField).toMatchObject({
|
|
id: 'field_8',
|
|
type: 'dropdown',
|
|
pageIndex: 1,
|
|
tooltip: 'Status tooltip',
|
|
options: ['Draft', 'Final'],
|
|
defaultValue: 'Final',
|
|
});
|
|
});
|
|
|
|
it('preserves widget geometry while resolving pages through /Annots fallback', async () => {
|
|
const { pdfDoc, page2Field } = await buildTwoPageTextFieldPdf();
|
|
|
|
const widget = page2Field.acroField.getWidgets()[0];
|
|
const rect = widget.getRectangle();
|
|
|
|
widget.dict.delete(PDFName.of('P'));
|
|
|
|
const extracted = extractFieldsForTest(pdfDoc);
|
|
const field = extracted.find((entry) => entry.name === 'page2TextField');
|
|
|
|
expect(field).toBeDefined();
|
|
expect(field).toMatchObject({
|
|
pageIndex: 1,
|
|
x: rect.x,
|
|
y: pdfDoc.getPages()[1].getSize().height - rect.y - rect.height,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
});
|
|
});
|
|
|
|
it('does not confuse annotation ownership across pages when multiple widgets exist', async () => {
|
|
const { pdfDoc, page1Field, page2Field } = await buildTwoPageTextFieldPdf();
|
|
|
|
const extraPage1 = pdfDoc.getForm().createTextField('page1ExtraField');
|
|
extraPage1.addToPage(pdfDoc.getPages()[0], {
|
|
x: 40,
|
|
y: 120,
|
|
width: 140,
|
|
height: 30,
|
|
borderColor: rgb(0, 0, 0),
|
|
});
|
|
|
|
page1Field.acroField.getWidgets()[0].dict.delete(PDFName.of('P'));
|
|
page2Field.acroField.getWidgets()[0].dict.delete(PDFName.of('P'));
|
|
extraPage1.acroField.getWidgets()[0].dict.delete(PDFName.of('P'));
|
|
|
|
const extracted = extractFieldsForTest(pdfDoc);
|
|
const pageMap = new Map(
|
|
extracted.map((field) => [field.name, field.pageIndex])
|
|
);
|
|
|
|
expect(pageMap.get('page1TextField')).toBe(0);
|
|
expect(pageMap.get('page1ExtraField')).toBe(0);
|
|
expect(pageMap.get('page2TextField')).toBe(1);
|
|
});
|
|
});
|