fix: form creator bug and added tests
This commit is contained in:
@@ -6,21 +6,32 @@ import {
|
||||
PDFName,
|
||||
PDFString,
|
||||
PageSizes,
|
||||
PDFBool,
|
||||
PDFDict,
|
||||
PDFArray,
|
||||
PDFRadioGroup,
|
||||
PDFTextField,
|
||||
PDFCheckBox,
|
||||
PDFDropdown,
|
||||
PDFOptionList,
|
||||
PDFButton,
|
||||
PDFSignature,
|
||||
} from 'pdf-lib';
|
||||
|
||||
type FormFieldAction = NonNullable<FormField['action']>;
|
||||
type FormFieldVisibilityAction = NonNullable<FormField['visibilityAction']>;
|
||||
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 =
|
||||
'<div class="flex flex-col items-center"><i data-lucide="pen-tool" class="w-6 h-6 mb-1"></i><span class="text-[10px]">Sign Here</span></div>';
|
||||
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 = `<div class="flex items-center gap-2 px-2"><i data-lucide="calendar" class="w-4 h-4"></i><span class="text-sm date-format-text">${field.dateFormat || 'mm/dd/yyyy'}</span></div>`;
|
||||
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 = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${field.label || 'Click to Upload Image'}</span></div>`;
|
||||
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 = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Invalid data</span></div>`;
|
||||
setTimeout(() => (window as any).lucide?.createIcons(), 0);
|
||||
setTimeout(() => (window as LucideWindow).lucide?.createIcons(), 0);
|
||||
}
|
||||
} else {
|
||||
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Barcode</span></div>`;
|
||||
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<string, any>(); // Track created radio groups
|
||||
const radioGroups = new Map<
|
||||
string,
|
||||
ReturnType<typeof form.createRadioGroup>
|
||||
>();
|
||||
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
clearInterval(checkRender);
|
||||
|
||||
const pageContainer =
|
||||
viewerWindow.document.querySelector('.page');
|
||||
viewerWindow.document.querySelector<HTMLElement>('.page');
|
||||
if (pageContainer) {
|
||||
const initialRect = pageContainer.getBoundingClientRect();
|
||||
|
||||
@@ -2797,15 +2826,20 @@ async function renderCanvas(): Promise<void> {
|
||||
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<string> = 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<any, number>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user