From e117ab93bdce37396ca87f36df61f533dd0a412e Mon Sep 17 00:00:00 2001 From: alam00000 Date: Thu, 12 Mar 2026 18:37:35 +0530 Subject: [PATCH] fix: form creator bug and added tests --- package-lock.json | 4 +- src/js/logic/form-creator-extraction.ts | 405 +++++++++++++++++++++ src/js/logic/form-creator.ts | 344 +++++------------- src/js/types/form-creator-type.ts | 63 +++- src/tests/form-creator-extraction.test.ts | 414 ++++++++++++++++++++++ 5 files changed, 962 insertions(+), 268 deletions(-) create mode 100644 src/js/logic/form-creator-extraction.ts create mode 100644 src/tests/form-creator-extraction.test.ts diff --git a/package-lock.json b/package-lock.json index 2576cc3..ff516b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bento-pdf", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bento-pdf", - "version": "2.4.1", + "version": "2.5.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource/cedarville-cursive": "^5.2.7", diff --git a/src/js/logic/form-creator-extraction.ts b/src/js/logic/form-creator-extraction.ts new file mode 100644 index 0000000..ad90f85 --- /dev/null +++ b/src/js/logic/form-creator-extraction.ts @@ -0,0 +1,405 @@ +import { + PDFArray, + PDFButton, + PDFCheckBox, + PDFDict, + PDFDocument, + PDFDropdown, + PDFName, + PDFOptionList, + PDFRadioGroup, + PDFRef, + PDFSignature, + PDFString, + PDFTextField, + PDFWidgetAnnotation, + TextAlignment, +} from 'pdf-lib'; +import type { + ExtractExistingFieldsOptions, + ExtractExistingFieldsResult, + ExtractionViewportMetrics, + FormField, +} from '@/types'; + +type SupportedPdfField = + | PDFTextField + | PDFCheckBox + | PDFRadioGroup + | PDFDropdown + | PDFOptionList + | PDFButton + | PDFSignature; + +type SupportedFieldType = FormField['type']; + +function isSupportedPdfField(field: unknown): field is SupportedPdfField { + return ( + field instanceof PDFTextField || + field instanceof PDFCheckBox || + field instanceof PDFRadioGroup || + field instanceof PDFDropdown || + field instanceof PDFOptionList || + field instanceof PDFButton || + field instanceof PDFSignature + ); +} + +function getSupportedFieldType(field: SupportedPdfField): SupportedFieldType { + if (field instanceof PDFTextField) return 'text'; + if (field instanceof PDFCheckBox) return 'checkbox'; + if (field instanceof PDFRadioGroup) return 'radio'; + if (field instanceof PDFDropdown) return 'dropdown'; + if (field instanceof PDFOptionList) return 'optionlist'; + if (field instanceof PDFButton) return 'button'; + return 'signature'; +} + +function getTooltip(widget: PDFWidgetAnnotation): string { + const tooltip = widget.dict.get(PDFName.of('TU')); + if (!(tooltip instanceof PDFString)) { + return ''; + } + + try { + return tooltip.decodeText(); + } catch (error) { + console.warn( + 'Failed to decode form field tooltip during extraction:', + error + ); + return ''; + } +} + +function getPageAnnotationRefs( + pdfDoc: PDFDocument, + pageIndex: number +): PDFRef[] { + const page = pdfDoc.getPages()[pageIndex]; + const annots = page.node.get(PDFName.of('Annots')); + if (!annots) return []; + + if (annots instanceof PDFArray) { + return annots + .asArray() + .map((entry) => { + if (entry instanceof PDFRef) { + return entry; + } + + return pdfDoc.context.getObjectRef(entry) ?? null; + }) + .filter((entry): entry is PDFRef => entry instanceof PDFRef); + } + + const annotsArray = pdfDoc.context.lookupMaybe(annots, PDFArray); + if (!annotsArray) return []; + + return annotsArray + .asArray() + .map((entry) => { + if (entry instanceof PDFRef) { + return entry; + } + + return pdfDoc.context.getObjectRef(entry) ?? null; + }) + .filter((entry): entry is PDFRef => entry instanceof PDFRef); +} + +export function resolveWidgetPageIndex( + pdfDoc: PDFDocument, + widget: PDFWidgetAnnotation +): number | null { + const pdfPages = pdfDoc.getPages(); + const pageRef = widget.P(); + + if (pageRef instanceof PDFRef) { + for (let pageIndex = 0; pageIndex < pdfPages.length; pageIndex += 1) { + if (pdfPages[pageIndex].ref === pageRef) { + return pageIndex; + } + } + } + + for (let pageIndex = 0; pageIndex < pdfPages.length; pageIndex += 1) { + const annotRefs = getPageAnnotationRefs(pdfDoc, pageIndex); + for (const annotRef of annotRefs) { + const annotDict = pdfDoc.context.lookupMaybe(annotRef, PDFDict); + if (annotDict === widget.dict) { + return pageIndex; + } + } + } + + return null; +} + +function buildBaseField( + pdfField: SupportedPdfField, + fieldType: SupportedFieldType, + widget: PDFWidgetAnnotation, + pageIndex: number, + id: string, + metrics: ExtractionViewportMetrics, + pdfDoc: PDFDocument +): FormField { + const rect = widget.getRectangle(); + const page = pdfDoc.getPages()[pageIndex]; + const { height: pageHeight } = page.getSize(); + + const canvasX = rect.x * metrics.pdfViewerScale + metrics.pdfViewerOffset.x; + const canvasY = + (pageHeight - rect.y - rect.height) * metrics.pdfViewerScale + + metrics.pdfViewerOffset.y; + const canvasWidth = rect.width * metrics.pdfViewerScale; + const canvasHeight = rect.height * metrics.pdfViewerScale; + + return { + id, + type: fieldType, + x: canvasX, + y: canvasY, + width: canvasWidth, + height: canvasHeight, + name: pdfField.getName(), + defaultValue: '', + fontSize: 12, + alignment: 'left', + textColor: '#000000', + required: pdfField.isRequired(), + readOnly: pdfField.isReadOnly(), + tooltip: getTooltip(widget), + combCells: 0, + maxLength: 0, + pageIndex, + borderColor: '#000000', + hideBorder: false, + }; +} + +function applyTextFieldDetails(field: FormField, pdfField: PDFTextField): void { + try { + field.defaultValue = pdfField.getText() || ''; + } catch (error) { + console.warn( + `Failed to read default text for field "${pdfField.getName()}" during extraction:`, + error + ); + } + + try { + field.multiline = pdfField.isMultiline(); + } catch (error) { + console.warn( + `Failed to read multiline setting for field "${pdfField.getName()}" during extraction:`, + error + ); + } + + try { + const maxLength = pdfField.getMaxLength(); + if (maxLength !== undefined) { + if (pdfField.isCombed()) { + field.combCells = maxLength; + } else { + field.maxLength = maxLength; + } + } + } catch (error) { + console.warn( + `Failed to read max length for field "${pdfField.getName()}" during extraction:`, + error + ); + } + + try { + const alignment = pdfField.getAlignment(); + if (alignment === TextAlignment.Center) field.alignment = 'center'; + else if (alignment === TextAlignment.Right) field.alignment = 'right'; + else field.alignment = 'left'; + } catch (error) { + console.warn( + `Failed to read alignment for field "${pdfField.getName()}" during extraction:`, + error + ); + field.alignment = 'left'; + } +} + +function applyChoiceFieldDetails( + field: FormField, + pdfField: PDFDropdown | PDFOptionList +): void { + try { + field.options = pdfField.getOptions(); + } catch (error) { + console.warn( + `Failed to read options for field "${pdfField.getName()}" during extraction:`, + error + ); + } + + try { + const selected = pdfField.getSelected(); + if (selected.length > 0) { + field.defaultValue = selected[0]; + } + } catch (error) { + console.warn( + `Failed to read selected option for field "${pdfField.getName()}" during extraction:`, + error + ); + } +} + +function buildStandardField( + pdfDoc: PDFDocument, + pdfField: Exclude, + widget: PDFWidgetAnnotation, + pageIndex: number, + id: string, + metrics: ExtractionViewportMetrics +): FormField { + const field = buildBaseField( + pdfField, + getSupportedFieldType(pdfField), + widget, + pageIndex, + id, + metrics, + pdfDoc + ); + + if (pdfField instanceof PDFTextField) { + applyTextFieldDetails(field, pdfField); + } else if (pdfField instanceof PDFCheckBox) { + try { + field.checked = pdfField.isChecked(); + } catch (error) { + console.warn( + `Failed to read checkbox state for field "${pdfField.getName()}" during extraction:`, + error + ); + field.checked = false; + } + field.exportValue = 'Yes'; + } else if (pdfField instanceof PDFDropdown) { + applyChoiceFieldDetails(field, pdfField); + } else if (pdfField instanceof PDFOptionList) { + applyChoiceFieldDetails(field, pdfField); + } else if (pdfField instanceof PDFButton) { + field.label = 'Button'; + field.action = 'none'; + } + + return field; +} + +function buildRadioField( + pdfDoc: PDFDocument, + pdfField: PDFRadioGroup, + widget: PDFWidgetAnnotation, + pageIndex: number, + id: string, + metrics: ExtractionViewportMetrics, + exportValue: string +): FormField { + const field = buildBaseField( + pdfField, + 'radio', + widget, + pageIndex, + id, + metrics, + pdfDoc + ); + + field.checked = false; + field.exportValue = exportValue; + field.groupName = pdfField.getName(); + + return field; +} + +export function extractExistingFields( + options: ExtractExistingFieldsOptions +): ExtractExistingFieldsResult { + const { pdfDoc, fieldCounterStart, metrics } = options; + const form = pdfDoc.getForm(); + const extractedFieldNames = new Set(); + const extractedFields: FormField[] = []; + let fieldCounter = fieldCounterStart; + + for (const rawField of form.getFields()) { + if (!isSupportedPdfField(rawField)) continue; + + const fieldName = rawField.getName(); + const widgets = rawField.acroField.getWidgets(); + if (widgets.length === 0) continue; + + if (rawField instanceof PDFRadioGroup) { + const options = rawField.getOptions(); + + for ( + let widgetIndex = 0; + widgetIndex < widgets.length; + widgetIndex += 1 + ) { + const widget = widgets[widgetIndex]; + const pageIndex = resolveWidgetPageIndex(pdfDoc, widget); + if (pageIndex === null) { + console.warn( + `Could not resolve page for existing radio widget "${fieldName}", skipping extraction` + ); + continue; + } + + fieldCounter += 1; + extractedFields.push( + buildRadioField( + pdfDoc, + rawField, + widget, + pageIndex, + `field_${fieldCounter}`, + metrics, + options[widgetIndex] || 'Yes' + ) + ); + } + + extractedFieldNames.add(fieldName); + continue; + } + + const widget = widgets[0]; + const pageIndex = resolveWidgetPageIndex(pdfDoc, widget); + if (pageIndex === null) { + console.warn( + `Could not resolve page for existing field "${fieldName}", skipping extraction` + ); + continue; + } + + fieldCounter += 1; + extractedFields.push( + buildStandardField( + pdfDoc, + rawField, + widget, + pageIndex, + `field_${fieldCounter}`, + metrics + ) + ); + extractedFieldNames.add(fieldName); + } + + return { + fields: extractedFields, + extractedFieldNames, + nextFieldCounter: fieldCounter, + }; +} diff --git a/src/js/logic/form-creator.ts b/src/js/logic/form-creator.ts index 65d35bf..15982bf 100644 --- a/src/js/logic/form-creator.ts +++ b/src/js/logic/form-creator.ts @@ -6,21 +6,32 @@ import { PDFName, PDFString, PageSizes, - PDFBool, PDFDict, PDFArray, PDFRadioGroup, - PDFTextField, - PDFCheckBox, - PDFDropdown, - PDFOptionList, - PDFButton, - PDFSignature, } from 'pdf-lib'; + +type FormFieldAction = NonNullable; +type FormFieldVisibilityAction = NonNullable; +type LucideWindow = Window & { + lucide?: { + createIcons(): void; + }; +}; +type PdfViewerApplicationLike = { + pdfViewer?: { + pagesCount: number; + }; +}; +type PdfViewerWindow = Window & { + PDFViewerApplication?: PdfViewerApplicationLike; +}; + import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js'; import { createIcons, icons } from 'lucide'; import * as pdfjsLib from 'pdfjs-dist'; +import type { PDFDocumentProxy } from 'pdfjs-dist'; import * as bwipjs from 'bwip-js/browser'; import 'pdfjs-dist/web/pdf_viewer.css'; @@ -30,7 +41,13 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( import.meta.url ).toString(); -import { FormField, PageData } from '../types/index.js'; +import { + ExtractExistingFieldsResult, + FormCreatorFieldType, + FormField, + PageData, +} from '@/types'; +import { extractExistingFields as extractExistingPdfFields } from './form-creator-extraction.js'; let fields: FormField[] = []; let selectedField: FormField | null = null; @@ -45,7 +62,7 @@ let pendingFieldExtraction = false; let pages: PageData[] = []; let currentPageIndex = 0; let uploadedPdfDoc: PDFDocument | null = null; -let uploadedPdfjsDoc: any = null; +let uploadedPdfjsDoc: PDFDocumentProxy | null = null; let pageSize: { width: number; height: number } = { width: 612, height: 792 }; let currentScale = 1.333; let pdfViewerOffset = { x: 0, y: 0 }; @@ -340,8 +357,9 @@ toolItems.forEach((item) => { ) { const x = touch.clientX - canvasRect.left - 75; const y = touch.clientY - canvasRect.top - 15; - const type = (item as HTMLElement).dataset.type || 'text'; - createField(type as any, x, y); + const type = ((item as HTMLElement).dataset.type || + 'text') as FormCreatorFieldType; + createField(type, x, y); } }); }); @@ -360,8 +378,9 @@ canvas.addEventListener('drop', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left - 75; const y = e.clientY - rect.top - 15; - const type = e.dataTransfer?.getData('text/plain') || 'text'; - createField(type as any, x, y); + const type = (e.dataTransfer?.getData('text/plain') || + 'text') as FormCreatorFieldType; + createField(type, x, y); }); canvas.addEventListener('click', (e) => { @@ -369,7 +388,7 @@ canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left - 75; const y = e.clientY - rect.top - 15; - createField(selectedToolType as any, x, y); + createField(selectedToolType as FormCreatorFieldType, x, y); toolItems.forEach((item) => item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600') @@ -584,17 +603,17 @@ function renderField(field: FormField): void { 'w-full h-full flex items-center justify-center bg-gray-50 text-gray-400'; contentEl.innerHTML = '
Sign Here
'; - setTimeout(() => (window as any).lucide?.createIcons(), 0); + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); } else if (field.type === 'date') { contentEl.className = 'w-full h-full flex items-center justify-center bg-white text-gray-600 border border-gray-300'; contentEl.innerHTML = `
${field.dateFormat || 'mm/dd/yyyy'}
`; - setTimeout(() => (window as any).lucide?.createIcons(), 0); + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); } else if (field.type === 'image') { contentEl.className = 'w-full h-full flex items-center justify-center bg-gray-100 text-gray-500 border border-gray-300'; contentEl.innerHTML = `
${field.label || 'Click to Upload Image'}
`; - setTimeout(() => (window as any).lucide?.createIcons(), 0); + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); } else if (field.type === 'barcode') { contentEl.className = 'w-full h-full flex items-center justify-center bg-white'; @@ -613,13 +632,17 @@ function renderField(field: FormField): void { img.src = offscreen.toDataURL('image/png'); img.className = 'max-w-full max-h-full object-contain'; contentEl.appendChild(img); - } catch { + } catch (error) { + console.warn( + `Failed to render barcode preview for field "${field.name}":`, + error + ); contentEl.innerHTML = `
Invalid data
`; - setTimeout(() => (window as any).lucide?.createIcons(), 0); + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); } } else { contentEl.innerHTML = `
Barcode
`; - setTimeout(() => (window as any).lucide?.createIcons(), 0); + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); } } @@ -1701,7 +1724,9 @@ function showProperties(field: FormField): void { ) as HTMLDivElement; propAction.addEventListener('change', (e) => { - field.action = (e.target as HTMLSelectElement).value as any; + const actionValue = (e.target as HTMLSelectElement) + .value as FormFieldAction; + field.action = actionValue; // Show/hide containers propUrlContainer.classList.add('hidden'); @@ -1747,7 +1772,8 @@ function showProperties(field: FormField): void { ) as HTMLSelectElement; if (propVisibilityAction) { propVisibilityAction.addEventListener('change', (e) => { - field.visibilityAction = (e.target as HTMLSelectElement).value as any; + field.visibilityAction = (e.target as HTMLSelectElement) + .value as FormFieldVisibilityAction; }); } } else if (field.type === 'signature') { @@ -1857,7 +1883,7 @@ function showProperties(field: FormField): void { textSpan.textContent = field.dateFormat; } } - setTimeout(() => (window as any).lucide?.createIcons(), 0); + setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0); }); } @@ -2068,7 +2094,10 @@ downloadBtn.addEventListener('click', async () => { pdfDoc.setAuthor('BentoPDF'); pdfDoc.setLanguage('en-US'); - const radioGroups = new Map(); // Track created radio groups + const radioGroups = new Map< + string, + ReturnType + >(); for (const field of fields) { const pageData = pages[field.pageIndex]; @@ -2187,7 +2216,7 @@ downloadBtn.addEventListener('click', async () => { } const borderRgb = hexToRgb(field.borderColor || '#000000'); - radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, { + radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage, { x: x, y: y, width: width, @@ -2200,7 +2229,7 @@ downloadBtn.addEventListener('click', async () => { if (field.required) radioGroup.enableRequired(); if (field.readOnly) radioGroup.enableReadOnly(); if (field.tooltip) { - radioGroup.acroField.getWidgets().forEach((widget: any) => { + radioGroup.acroField.getWidgets().forEach((widget) => { widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); }); } @@ -2284,7 +2313,7 @@ downloadBtn.addEventListener('click', async () => { const widgets = button.acroField.getWidgets(); widgets.forEach((widget) => { - let actionDict: any; + let actionDict: PDFDict | PDFArray | undefined; if (field.action === 'reset') { actionDict = pdfDoc.context.obj({ @@ -2325,7 +2354,7 @@ downloadBtn.addEventListener('click', async () => { }); } else if (field.action === 'showHide' && field.targetFieldName) { const target = field.targetFieldName; - let script = ''; + let script: string; if (field.visibilityAction === 'show') { script = `var f = this.getField("${target}"); if(f) f.display = display.visible;`; @@ -2682,7 +2711,7 @@ async function renderCanvas(): Promise { iframe.onload = () => { try { - const viewerWindow = iframe.contentWindow as any; + const viewerWindow = iframe.contentWindow as PdfViewerWindow | null; if (viewerWindow && viewerWindow.PDFViewerApplication) { const app = viewerWindow.PDFViewerApplication; @@ -2741,7 +2770,7 @@ async function renderCanvas(): Promise { clearInterval(checkRender); const pageContainer = - viewerWindow.document.querySelector('.page'); + viewerWindow.document.querySelector('.page'); if (pageContainer) { const initialRect = pageContainer.getBoundingClientRect(); @@ -2797,15 +2826,20 @@ async function renderCanvas(): Promise { existingFieldNames.delete(name) ); - try { - const form = uploadedPdfDoc.getForm(); - for (const name of extractedFieldNames) { - try { - const f = form.getFieldMaybe(name); - if (f) form.removeField(f); - } catch {} + const form = uploadedPdfDoc.getForm(); + for (const name of extractedFieldNames) { + try { + const existingField = form.getFieldMaybe(name); + if (existingField) { + form.removeField(existingField); + } + } catch (error) { + console.warn( + `Failed to remove extracted field "${name}" after import:`, + error + ); } - } catch {} + } renderCanvas(); updateFieldCount(); @@ -2904,228 +2938,28 @@ const extractedFieldNames: Set = new Set(); function extractExistingFields(pdfDoc: PDFDocument): void { try { - const form = pdfDoc.getForm(); - const pdfFields = form.getFields(); - const pdfPages = pdfDoc.getPages(); + const extractionResult: ExtractExistingFieldsResult = + extractExistingPdfFields({ + pdfDoc, + fieldCounterStart: fieldCounter, + metrics: { + pdfViewerOffset, + pdfViewerScale, + }, + }); - const pageRefToIndex = new Map(); - pdfPages.forEach((page, index) => { - pageRefToIndex.set(page.ref, index); + fields.push(...extractionResult.fields); + fieldCounter = extractionResult.nextFieldCounter; + + extractionResult.extractedFieldNames.forEach((name) => { + extractedFieldNames.add(name); }); - for (const pdfField of pdfFields) { - const name = pdfField.getName(); - const widgets = pdfField.acroField.getWidgets(); - - if (widgets.length === 0) continue; - - let fieldType: FormField['type']; - if (pdfField instanceof PDFTextField) { - fieldType = 'text'; - } else if (pdfField instanceof PDFCheckBox) { - fieldType = 'checkbox'; - } else if (pdfField instanceof PDFRadioGroup) { - fieldType = 'radio'; - } else if (pdfField instanceof PDFDropdown) { - fieldType = 'dropdown'; - } else if (pdfField instanceof PDFOptionList) { - fieldType = 'optionlist'; - } else if (pdfField instanceof PDFButton) { - fieldType = 'button'; - } else if (pdfField instanceof PDFSignature) { - fieldType = 'signature'; - } else { - continue; - } - - if (fieldType === 'radio') { - const radioField = pdfField as PDFRadioGroup; - const options = radioField.getOptions(); - - for (let wi = 0; wi < widgets.length; wi++) { - const widget = widgets[wi]; - const rect = widget.getRectangle(); - - const pageRef = widget.dict.get(PDFName.of('P')); - let pageIndex = 0; - if (pageRef) { - for (let pi = 0; pi < pdfPages.length; pi++) { - if (pdfPages[pi].ref === pageRef) { - pageIndex = pi; - break; - } - } - } - - const page = pdfPages[pageIndex]; - const { height: pageHeight } = page.getSize(); - - const canvasX = rect.x * pdfViewerScale + pdfViewerOffset.x; - const canvasY = - (pageHeight - rect.y - rect.height) * pdfViewerScale + - pdfViewerOffset.y; - const canvasWidth = rect.width * pdfViewerScale; - const canvasHeight = rect.height * pdfViewerScale; - - fieldCounter++; - const exportValue = options[wi] || 'Yes'; - - let tooltip = ''; - try { - const tu = widget.dict.get(PDFName.of('TU')); - if (tu instanceof PDFString) { - tooltip = tu.decodeText(); - } - } catch {} - - const formField: FormField = { - id: `field_${fieldCounter}`, - type: 'radio', - x: canvasX, - y: canvasY, - width: canvasWidth, - height: canvasHeight, - name: name, - defaultValue: '', - fontSize: 12, - alignment: 'left', - textColor: '#000000', - required: radioField.isRequired(), - readOnly: radioField.isReadOnly(), - tooltip: tooltip, - combCells: 0, - maxLength: 0, - checked: false, - exportValue: exportValue, - groupName: name, - pageIndex: pageIndex, - borderColor: '#000000', - hideBorder: false, - }; - - fields.push(formField); - } - - extractedFieldNames.add(name); - continue; - } - - const widget = widgets[0]; - const rect = widget.getRectangle(); - - const pageRef = widget.dict.get(PDFName.of('P')); - let pageIndex = 0; - if (pageRef) { - for (let pi = 0; pi < pdfPages.length; pi++) { - if (pdfPages[pi].ref === pageRef) { - pageIndex = pi; - break; - } - } - } - - const page = pdfPages[pageIndex]; - const { height: pageHeight } = page.getSize(); - - const canvasX = rect.x * pdfViewerScale + pdfViewerOffset.x; - const canvasY = - (pageHeight - rect.y - rect.height) * pdfViewerScale + - pdfViewerOffset.y; - const canvasWidth = rect.width * pdfViewerScale; - const canvasHeight = rect.height * pdfViewerScale; - - let tooltip = ''; - try { - const tu = widget.dict.get(PDFName.of('TU')); - if (tu instanceof PDFString) { - tooltip = tu.decodeText(); - } - } catch {} - - fieldCounter++; - - const formField: FormField = { - id: `field_${fieldCounter}`, - type: fieldType, - x: canvasX, - y: canvasY, - width: canvasWidth, - height: canvasHeight, - name: name, - defaultValue: '', - fontSize: 12, - alignment: 'left', - textColor: '#000000', - required: pdfField.isRequired(), - readOnly: pdfField.isReadOnly(), - tooltip: tooltip, - combCells: 0, - maxLength: 0, - pageIndex: pageIndex, - borderColor: '#000000', - hideBorder: false, - }; - - if (pdfField instanceof PDFTextField) { - try { - formField.defaultValue = pdfField.getText() || ''; - } catch {} - try { - formField.multiline = pdfField.isMultiline(); - } catch {} - try { - const maxLen = pdfField.getMaxLength(); - if (maxLen !== undefined) { - if (pdfField.isCombed()) { - formField.combCells = maxLen; - } else { - formField.maxLength = maxLen; - } - } - } catch {} - try { - const alignment = pdfField.getAlignment(); - if (alignment === TextAlignment.Center) - formField.alignment = 'center'; - else if (alignment === TextAlignment.Right) - formField.alignment = 'right'; - else formField.alignment = 'left'; - } catch {} - } else if (pdfField instanceof PDFCheckBox) { - try { - formField.checked = pdfField.isChecked(); - } catch {} - formField.exportValue = 'Yes'; - } else if (pdfField instanceof PDFDropdown) { - try { - formField.options = pdfField.getOptions(); - } catch {} - try { - const selected = pdfField.getSelected(); - if (selected.length > 0) formField.defaultValue = selected[0]; - } catch {} - } else if (pdfField instanceof PDFOptionList) { - try { - formField.options = pdfField.getOptions(); - } catch {} - try { - const selected = pdfField.getSelected(); - if (selected.length > 0) formField.defaultValue = selected[0]; - } catch {} - } else if (pdfField instanceof PDFButton) { - formField.label = 'Button'; - formField.action = 'none'; - } - - fields.push(formField); - extractedFieldNames.add(name); - } - console.log( - `Extracted ${extractedFieldNames.size} existing fields for editing` + `Extracted ${extractionResult.extractedFieldNames.size} existing fields for editing` ); - } catch (e) { - console.warn('Error extracting existing fields:', e); + } catch (error) { + console.warn('Error extracting existing fields:', error); } } diff --git a/src/js/types/form-creator-type.ts b/src/js/types/form-creator-type.ts index 58460a1..9009d12 100644 --- a/src/js/types/form-creator-type.ts +++ b/src/js/types/form-creator-type.ts @@ -1,16 +1,57 @@ +export const FORM_CREATOR_FIELD_TYPES = [ + 'text', + 'checkbox', + 'radio', + 'dropdown', + 'optionlist', + 'button', + 'signature', + 'date', + 'image', + 'barcode', +] as const; + +export type FormCreatorFieldType = (typeof FORM_CREATOR_FIELD_TYPES)[number]; + +export interface ExtractionViewportMetrics { + pdfViewerOffset: { + x: number; + y: number; + }; + pdfViewerScale: number; +} + +export interface ExtractExistingFieldsOptions { + pdfDoc: import('pdf-lib').PDFDocument; + fieldCounterStart: number; + metrics: ExtractionViewportMetrics; +} + +export interface ExtractExistingFieldsResult { + fields: FormField[]; + extractedFieldNames: Set; + nextFieldCounter: number; +} + +export interface ExtractedFieldLike { + type: 'text' | 'radio'; + name: string; + pageIndex: number; + x: number; + y: number; + width: number; + height: number; + tooltip: string; + required: boolean; + readOnly: boolean; + checked?: boolean; + exportValue?: string; + groupName?: string; +} + export interface FormField { id: string; - type: - | 'text' - | 'checkbox' - | 'radio' - | 'dropdown' - | 'optionlist' - | 'button' - | 'signature' - | 'date' - | 'image' - | 'barcode'; + type: FormCreatorFieldType; x: number; y: number; width: number; diff --git a/src/tests/form-creator-extraction.test.ts b/src/tests/form-creator-extraction.test.ts new file mode 100644 index 0000000..d2ff5c8 --- /dev/null +++ b/src/tests/form-creator-extraction.test.ts @@ -0,0 +1,414 @@ +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); + }); +});