feat: add barcode field support in form creator and support to edit existing form fields

This commit is contained in:
alam00000
2026-03-01 19:48:34 +05:30
parent d66287a55b
commit e8563a1392
5 changed files with 487 additions and 44 deletions

14
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "bento-pdf", "name": "bento-pdf",
"version": "2.2.1", "version": "2.3.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "bento-pdf", "name": "bento-pdf",
"version": "2.2.1", "version": "2.3.3",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@fontsource/cedarville-cursive": "^5.2.7", "@fontsource/cedarville-cursive": "^5.2.7",
@@ -28,6 +28,7 @@
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"blob-stream": "^0.1.3", "blob-stream": "^0.1.3",
"bwip-js": "^4.8.0",
"cropperjs": "^1.6.1", "cropperjs": "^1.6.1",
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.3.0.tgz", "embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.3.0.tgz",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
@@ -5150,6 +5151,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bwip-js": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.8.0.tgz",
"integrity": "sha512-gUDkDHSTv8/DJhomSIbO0fX/Dx0MO/sgllLxJyJfu4WixCQe9nfGJzmHm64ZCbxo+gUYQEsQcRmqcwcwPRwUkg==",
"license": "MIT",
"bin": {
"bwip-js": "bin/bwip-js.js"
}
},
"node_modules/cac": { "node_modules/cac": {
"version": "6.7.14", "version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",

View File

@@ -84,6 +84,7 @@
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"blob-stream": "^0.1.3", "blob-stream": "^0.1.3",
"bwip-js": "^4.8.0",
"cropperjs": "^1.6.1", "cropperjs": "^1.6.1",
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.3.0.tgz", "embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.3.0.tgz",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",

View File

@@ -10,11 +10,18 @@ import {
PDFDict, PDFDict,
PDFArray, PDFArray,
PDFRadioGroup, PDFRadioGroup,
PDFTextField,
PDFCheckBox,
PDFDropdown,
PDFOptionList,
PDFButton,
PDFSignature,
} from 'pdf-lib'; } from 'pdf-lib';
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js'; import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide'; import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import * as bwipjs from 'bwip-js/browser';
import 'pdfjs-dist/web/pdf_viewer.css'; import 'pdfjs-dist/web/pdf_viewer.css';
// Initialize PDF.js worker // Initialize PDF.js worker
@@ -33,6 +40,7 @@ const existingRadioGroups: Set<string> = new Set();
let draggedElement: HTMLElement | null = null; let draggedElement: HTMLElement | null = null;
let offsetX = 0; let offsetX = 0;
let offsetY = 0; let offsetY = 0;
let pendingFieldExtraction = false;
let pages: PageData[] = []; let pages: PageData[] = [];
let currentPageIndex = 0; let currentPageIndex = 0;
@@ -384,8 +392,18 @@ function createField(type: FormField['type'], x: number, y: number): void {
type: type, type: type,
x: Math.max(0, Math.min(x, 816 - 150)), x: Math.max(0, Math.min(x, 816 - 150)),
y: Math.max(0, Math.min(y, 1056 - 30)), y: Math.max(0, Math.min(y, 1056 - 30)),
width: type === 'checkbox' || type === 'radio' ? 30 : 150, width:
height: type === 'checkbox' || type === 'radio' ? 30 : 30, type === 'checkbox' || type === 'radio'
? 30
: type === 'barcode'
? 150
: 150,
height:
type === 'checkbox' || type === 'radio'
? 30
: type === 'barcode'
? 150
: 30,
name: `${type.charAt(0).toUpperCase() + type.slice(1)}_${fieldCounter}`, name: `${type.charAt(0).toUpperCase() + type.slice(1)}_${fieldCounter}`,
defaultValue: '', defaultValue: '',
fontSize: 12, fontSize: 12,
@@ -417,6 +435,8 @@ function createField(type: FormField['type'], x: number, y: number): void {
multiline: type === 'text' ? false : undefined, multiline: type === 'text' ? false : undefined,
borderColor: '#000000', borderColor: '#000000',
hideBorder: false, hideBorder: false,
barcodeFormat: type === 'barcode' ? 'qrcode' : undefined,
barcodeValue: type === 'barcode' ? 'https://example.com' : undefined,
}; };
fields.push(field); fields.push(field);
@@ -575,6 +595,32 @@ function renderField(field: FormField): void {
'w-full h-full flex items-center justify-center bg-gray-100 text-gray-500 border border-gray-300'; '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>`; 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 any).lucide?.createIcons(), 0);
} else if (field.type === 'barcode') {
contentEl.className =
'w-full h-full flex items-center justify-center bg-white';
if (field.barcodeValue) {
try {
const offscreen = document.createElement('canvas');
bwipjs.toCanvas(offscreen, {
bcid: field.barcodeFormat || 'qrcode',
text: field.barcodeValue,
scale: 2,
includetext:
field.barcodeFormat !== 'qrcode' &&
field.barcodeFormat !== 'datamatrix',
});
const img = document.createElement('img');
img.src = offscreen.toDataURL('image/png');
img.className = 'max-w-full max-h-full object-contain';
contentEl.appendChild(img);
} catch {
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);
}
} 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);
}
} }
fieldContainer.appendChild(contentEl); fieldContainer.appendChild(contentEl);
@@ -1114,6 +1160,26 @@ function showProperties(field: FormField): void {
Clicking this field in the PDF will open a file picker to upload an image. Clicking this field in the PDF will open a file picker to upload an image.
</div> </div>
`; `;
} else if (field.type === 'barcode') {
specificProps = `
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Format</label>
<select id="propBarcodeFormat" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
<option value="qrcode" ${field.barcodeFormat === 'qrcode' ? 'selected' : ''}>QR Code</option>
<option value="code128" ${field.barcodeFormat === 'code128' ? 'selected' : ''}>Code 128</option>
<option value="code39" ${field.barcodeFormat === 'code39' ? 'selected' : ''}>Code 39</option>
<option value="ean13" ${field.barcodeFormat === 'ean13' ? 'selected' : ''}>EAN-13</option>
<option value="upca" ${field.barcodeFormat === 'upca' ? 'selected' : ''}>UPC-A</option>
<option value="datamatrix" ${field.barcodeFormat === 'datamatrix' ? 'selected' : ''}>DataMatrix</option>
<option value="pdf417" ${field.barcodeFormat === 'pdf417' ? 'selected' : ''}>PDF417</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
<input type="text" id="propBarcodeValue" value="${field.barcodeValue || ''}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div>
`;
} }
propertiesPanel.innerHTML = ` propertiesPanel.innerHTML = `
@@ -1795,6 +1861,56 @@ function showProperties(field: FormField): void {
field.label = (e.target as HTMLInputElement).value; field.label = (e.target as HTMLInputElement).value;
renderField(field); renderField(field);
}); });
} else if (field.type === 'barcode') {
const propBarcodeFormat = document.getElementById(
'propBarcodeFormat'
) as HTMLSelectElement;
const propBarcodeValue = document.getElementById(
'propBarcodeValue'
) as HTMLInputElement;
const barcodeSampleValues: Record<string, string> = {
qrcode: 'https://example.com',
code128: 'ABC-123',
code39: 'ABC123',
ean13: '590123412345',
upca: '01234567890',
datamatrix: 'https://example.com',
pdf417: 'https://example.com',
};
const barcodeFormatHints: Record<string, string> = {
qrcode: 'Any text, URL, or data',
code128: 'ASCII characters (letters, numbers, symbols)',
code39: 'Uppercase A-Z, digits 0-9, and - . $ / + % SPACE',
ean13: 'Exactly 12 or 13 digits',
upca: 'Exactly 11 or 12 digits',
datamatrix: 'Any text, URL, or data',
pdf417: 'Any text, URL, or data',
};
const hintEl = document.getElementById('barcodeFormatHint');
if (hintEl)
hintEl.textContent =
barcodeFormatHints[field.barcodeFormat || 'qrcode'] || '';
if (propBarcodeFormat) {
propBarcodeFormat.addEventListener('change', (e) => {
const newFormat = (e.target as HTMLSelectElement).value;
field.barcodeFormat = newFormat;
field.barcodeValue = barcodeSampleValues[newFormat] || 'hello';
if (propBarcodeValue) propBarcodeValue.value = field.barcodeValue;
if (hintEl) hintEl.textContent = barcodeFormatHints[newFormat] || '';
renderField(field);
});
}
if (propBarcodeValue) {
propBarcodeValue.addEventListener('input', (e) => {
field.barcodeValue = (e.target as HTMLInputElement).value;
renderField(field);
});
}
} }
} }
@@ -1911,6 +2027,19 @@ downloadBtn.addEventListener('click', async () => {
const form = pdfDoc.getForm(); const form = pdfDoc.getForm();
if (extractedFieldNames.size > 0) {
for (const fieldName of extractedFieldNames) {
try {
const existingField = form.getFieldMaybe(fieldName);
if (existingField) {
form.removeField(existingField);
}
} catch (e) {
console.warn(`Failed to remove existing field "${fieldName}":`, e);
}
}
}
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica); const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
// Set document metadata for accessibility // Set document metadata for accessibility
@@ -2332,6 +2461,32 @@ downloadBtn.addEventListener('click', async () => {
if (field.tooltip) { if (field.tooltip) {
widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip)); widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
} }
} else if (field.type === 'barcode') {
if (field.barcodeValue) {
try {
const offscreen = document.createElement('canvas');
bwipjs.toCanvas(offscreen, {
bcid: field.barcodeFormat || 'qrcode',
text: field.barcodeValue,
scale: 3,
includetext:
field.barcodeFormat !== 'qrcode' &&
field.barcodeFormat !== 'datamatrix',
});
const dataUrl = offscreen.toDataURL('image/png');
const base64 = dataUrl.split(',')[1];
const pngBytes = Uint8Array.from(atob(base64), (c) =>
c.charCodeAt(0)
);
const pngImage = await pdfDoc.embedPng(pngBytes);
pdfPage.drawImage(pngImage, { x, y, width, height });
} catch (e) {
console.warn(
`Failed to generate barcode for field "${field.name}":`,
e
);
}
}
} }
} }
@@ -2429,6 +2584,8 @@ function resetToInitial(): void {
currentPageIndex = 0; currentPageIndex = 0;
uploadedPdfDoc = null; uploadedPdfDoc = null;
selectedField = null; selectedField = null;
extractedFieldNames.clear();
pendingFieldExtraction = false;
canvas.innerHTML = ''; canvas.innerHTML = '';
@@ -2611,6 +2768,27 @@ async function renderCanvas(): Promise<void> {
height: currentPage.height, height: currentPage.height,
}, },
}); });
if (pendingFieldExtraction && uploadedPdfDoc) {
pendingFieldExtraction = false;
extractExistingFields(uploadedPdfDoc);
extractedFieldNames.forEach((name) =>
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 {}
}
} catch {}
renderCanvas();
updateFieldCount();
}
}, 50); }, 50);
} }
} }
@@ -2701,6 +2879,235 @@ confirmBlankBtn.addEventListener('click', () => {
setTimeout(() => createIcons({ icons }), 100); setTimeout(() => createIcons({ icons }), 100);
}); });
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 pageRefToIndex = new Map<any, number>();
pdfPages.forEach((page, index) => {
pageRefToIndex.set(page.ref, index);
});
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`
);
} catch (e) {
console.warn('Error extracting existing fields:', e);
}
}
async function handlePdfUpload(file: File) { async function handlePdfUpload(file: File) {
try { try {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
@@ -2759,6 +3166,9 @@ async function handlePdfUpload(file: File) {
} }
currentPageIndex = 0; currentPageIndex = 0;
pendingFieldExtraction = true;
renderCanvas(); renderCanvas();
updatePageNavigation(); updatePageNavigation();

View File

@@ -1,40 +1,52 @@
export interface FormField { export interface FormField {
id: string id: string;
type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'optionlist' | 'button' | 'signature' | 'date' | 'image' type:
x: number | 'text'
y: number | 'checkbox'
width: number | 'radio'
height: number | 'dropdown'
name: string | 'optionlist'
defaultValue: string | 'button'
fontSize: number | 'signature'
alignment: 'left' | 'center' | 'right' | 'date'
textColor: string | 'image'
required: boolean | 'barcode';
readOnly: boolean x: number;
tooltip: string y: number;
combCells: number width: number;
maxLength: number height: number;
options?: string[] name: string;
checked?: boolean defaultValue: string;
exportValue?: string fontSize: number;
groupName?: string alignment: 'left' | 'center' | 'right';
label?: string textColor: string;
pageIndex: number required: boolean;
action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide' readOnly: boolean;
actionUrl?: string tooltip: string;
jsScript?: string combCells: number;
targetFieldName?: string maxLength: number;
visibilityAction?: 'show' | 'hide' | 'toggle' options?: string[];
dateFormat?: string checked?: boolean;
multiline?: boolean exportValue?: string;
borderColor?: string groupName?: string;
hideBorder?: boolean label?: string;
pageIndex: number;
action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide';
actionUrl?: string;
jsScript?: string;
targetFieldName?: string;
visibilityAction?: 'show' | 'hide' | 'toggle';
dateFormat?: string;
multiline?: boolean;
borderColor?: string;
hideBorder?: boolean;
barcodeFormat?: string;
barcodeValue?: string;
} }
export interface PageData { export interface PageData {
index: number index: number;
width: number width: number;
height: number height: number;
pdfPageData?: string pdfPageData?: string;
} }

View File

@@ -504,6 +504,16 @@
<i data-lucide="image" class="w-4 h-4"></i> <i data-lucide="image" class="w-4 h-4"></i>
<span class="text-xs">Image</span> <span class="text-xs">Image</span>
</div> </div>
<div
class="tool-item bg-gray-600 hover:bg-indigo-600 text-white p-2 rounded cursor-move transition-colors flex items-center gap-2"
draggable="true"
data-type="barcode"
title="Barcode Field"
>
<i data-lucide="qr-code" class="w-4 h-4"></i>
<span class="text-xs">Barcode</span>
</div>
</div> </div>
</div> </div>
<div class="bg-gray-700 rounded-lg p-4 mb-4 overflow-auto w-full"> <div class="bg-gray-700 rounded-lg p-4 mb-4 overflow-auto w-full">