@@ -930,27 +1113,47 @@ function showProperties(field: FormField): void {
Clicking this field in the PDF will open a file picker to upload an image.
- `
- }
+ `;
+ }
- propertiesPanel.innerHTML = `
+ propertiesPanel.innerHTML = `
- ${field.type === 'radio' && (existingRadioGroups.size > 0 || fields.some(f => f.type === 'radio' && f.id !== field.id)) ? `
+ ${
+ field.type === 'radio' &&
+ (existingRadioGroups.size > 0 ||
+ fields.some((f) => f.type === 'radio' && f.id !== field.id))
+ ? `
Existing Radio Groups
-- Select existing group --
- ${Array.from(existingRadioGroups).map(name => `${name} `).join('')}
- ${Array.from(new Set(fields.filter(f => f.type === 'radio' && f.id !== field.id).map(f => f.name))).map(name => !existingRadioGroups.has(name) ? `${name} ` : '').join('')}
+ ${Array.from(existingRadioGroups)
+ .map((name) => `${name} `)
+ .join('')}
+ ${Array.from(
+ new Set(
+ fields
+ .filter((f) => f.type === 'radio' && f.id !== field.id)
+ .map((f) => f.name)
+ )
+ )
+ .map((name) =>
+ !existingRadioGroups.has(name)
+ ? `${name} `
+ : ''
+ )
+ .join('')}
Select to add this button to an existing group
- ` : ''}
+ `
+ : ''
+ }
${specificProps}
Tooltip / Help Text
@@ -976,1113 +1179,1337 @@ function showProperties(field: FormField): void {
Delete Field
- `
-
- // Common listeners
- const propName = document.getElementById('propName') as HTMLInputElement
- const nameError = document.getElementById('nameError') as HTMLDivElement
- const propTooltip = document.getElementById('propTooltip') as HTMLInputElement
- const propRequired = document.getElementById('propRequired') as HTMLInputElement
- const propReadOnly = document.getElementById('propReadOnly') as HTMLInputElement
- const deleteBtn = document.getElementById('deleteBtn') as HTMLButtonElement
-
- const validateName = (newName: string): boolean => {
- if (!newName) {
- nameError.textContent = 'Field name cannot be empty'
- nameError.classList.remove('hidden')
- propName.classList.add('border-red-500')
- return false
- }
-
- if (field.type === 'radio') {
- nameError.classList.add('hidden')
- propName.classList.remove('border-red-500')
- return true
- }
+ `;
+
+ // Common listeners
+ const propName = document.getElementById('propName') as HTMLInputElement;
+ const nameError = document.getElementById('nameError') as HTMLDivElement;
+ const propTooltip = document.getElementById(
+ 'propTooltip'
+ ) as HTMLInputElement;
+ const propRequired = document.getElementById(
+ 'propRequired'
+ ) as HTMLInputElement;
+ const propReadOnly = document.getElementById(
+ 'propReadOnly'
+ ) as HTMLInputElement;
+ const deleteBtn = document.getElementById('deleteBtn') as HTMLButtonElement;
+
+ const validateName = (newName: string): boolean => {
+ if (!newName) {
+ nameError.textContent = 'Field name cannot be empty';
+ nameError.classList.remove('hidden');
+ propName.classList.add('border-red-500');
+ return false;
+ }
- const isDuplicateInFields = fields.some(f => f.id !== field.id && f.name === newName)
- const isDuplicateInPdf = existingFieldNames.has(newName)
+ if (field.type === 'radio') {
+ nameError.classList.add('hidden');
+ propName.classList.remove('border-red-500');
+ return true;
+ }
- if (isDuplicateInFields || isDuplicateInPdf) {
- nameError.textContent = `Field name "${newName}" already exists in this ${isDuplicateInPdf ? 'PDF' : 'form'}. Please try using a unique name.`
- nameError.classList.remove('hidden')
- propName.classList.add('border-red-500')
- return false
- }
+ const isDuplicateInFields = fields.some(
+ (f) => f.id !== field.id && f.name === newName
+ );
+ const isDuplicateInPdf = existingFieldNames.has(newName);
- nameError.classList.add('hidden')
- propName.classList.remove('border-red-500')
- return true
+ if (isDuplicateInFields || isDuplicateInPdf) {
+ nameError.textContent = `Field name "${newName}" already exists in this ${isDuplicateInPdf ? 'PDF' : 'form'}. Please try using a unique name.`;
+ nameError.classList.remove('hidden');
+ propName.classList.add('border-red-500');
+ return false;
}
- propName.addEventListener('input', (e) => {
- const newName = (e.target as HTMLInputElement).value.trim()
- validateName(newName)
- })
+ nameError.classList.add('hidden');
+ propName.classList.remove('border-red-500');
+ return true;
+ };
- propName.addEventListener('change', (e) => {
- const newName = (e.target as HTMLInputElement).value.trim()
+ propName.addEventListener('input', (e) => {
+ const newName = (e.target as HTMLInputElement).value.trim();
+ validateName(newName);
+ });
- if (!validateName(newName)) {
- (e.target as HTMLInputElement).value = field.name
- return
- }
+ propName.addEventListener('change', (e) => {
+ const newName = (e.target as HTMLInputElement).value.trim();
- field.name = newName
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const label = fieldWrapper.querySelector('.field-label') as HTMLElement
- if (label) label.textContent = field.name
- }
- })
-
- propTooltip.addEventListener('input', (e) => {
- field.tooltip = (e.target as HTMLInputElement).value
- })
+ if (!validateName(newName)) {
+ (e.target as HTMLInputElement).value = field.name;
+ return;
+ }
- if (field.type === 'radio') {
- const existingGroupsSelect = document.getElementById('existingGroups') as HTMLSelectElement
- if (existingGroupsSelect) {
- existingGroupsSelect.addEventListener('change', (e) => {
- const selectedGroup = (e.target as HTMLSelectElement).value
- if (selectedGroup) {
- propName.value = selectedGroup
- field.name = selectedGroup
- validateName(selectedGroup)
-
- // Update field label
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const label = fieldWrapper.querySelector('.field-label') as HTMLElement
- if (label) label.textContent = field.name
- }
- }
- })
+ field.name = newName;
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const label = fieldWrapper.querySelector('.field-label') as HTMLElement;
+ if (label) label.textContent = field.name;
+ }
+ });
+
+ propTooltip.addEventListener('input', (e) => {
+ field.tooltip = (e.target as HTMLInputElement).value;
+ });
+
+ if (field.type === 'radio') {
+ const existingGroupsSelect = document.getElementById(
+ 'existingGroups'
+ ) as HTMLSelectElement;
+ if (existingGroupsSelect) {
+ existingGroupsSelect.addEventListener('change', (e) => {
+ const selectedGroup = (e.target as HTMLSelectElement).value;
+ if (selectedGroup) {
+ propName.value = selectedGroup;
+ field.name = selectedGroup;
+ validateName(selectedGroup);
+
+ // Update field label
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const label = fieldWrapper.querySelector(
+ '.field-label'
+ ) as HTMLElement;
+ if (label) label.textContent = field.name;
+ }
}
+ });
}
+ }
+
+ propRequired.addEventListener('change', (e) => {
+ field.required = (e.target as HTMLInputElement).checked;
+ });
+
+ propReadOnly.addEventListener('change', (e) => {
+ field.readOnly = (e.target as HTMLInputElement).checked;
+ });
+
+ const propBorderColor = document.getElementById(
+ 'propBorderColor'
+ ) as HTMLInputElement;
+ const propHideBorder = document.getElementById(
+ 'propHideBorder'
+ ) as HTMLInputElement;
+
+ propBorderColor.addEventListener('input', (e) => {
+ field.borderColor = (e.target as HTMLInputElement).value;
+ });
+
+ propHideBorder.addEventListener('change', (e) => {
+ field.hideBorder = (e.target as HTMLInputElement).checked;
+ });
+
+ deleteBtn.addEventListener('click', () => {
+ deleteField(field);
+ });
+
+ // Specific listeners
+ if (field.type === 'text') {
+ const propValue = document.getElementById('propValue') as HTMLInputElement;
+ const propMaxLength = document.getElementById(
+ 'propMaxLength'
+ ) as HTMLInputElement;
+ const propComb = document.getElementById('propComb') as HTMLInputElement;
+ const propFontSize = document.getElementById(
+ 'propFontSize'
+ ) as HTMLInputElement;
+ const propTextColor = document.getElementById(
+ 'propTextColor'
+ ) as HTMLInputElement;
+ const propAlignment = document.getElementById(
+ 'propAlignment'
+ ) as HTMLSelectElement;
+
+ propValue.addEventListener('input', (e) => {
+ field.defaultValue = (e.target as HTMLInputElement).value;
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
+ if (textEl) textEl.textContent = field.defaultValue;
+ }
+ });
+
+ propMaxLength.addEventListener('input', (e) => {
+ const val = parseInt((e.target as HTMLInputElement).value);
+ field.maxLength = isNaN(val) ? 0 : Math.max(0, val);
+ if (field.maxLength > 0) {
+ propValue.maxLength = field.maxLength;
+ if (field.defaultValue.length > field.maxLength) {
+ field.defaultValue = field.defaultValue.substring(0, field.maxLength);
+ propValue.value = field.defaultValue;
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const textEl = fieldWrapper.querySelector(
+ '.field-text'
+ ) as HTMLElement;
+ if (textEl) textEl.textContent = field.defaultValue;
+ }
+ }
+ } else {
+ propValue.removeAttribute('maxLength');
+ }
+ });
+
+ propComb.addEventListener('input', (e) => {
+ const val = parseInt((e.target as HTMLInputElement).value);
+ field.combCells = isNaN(val) ? 0 : Math.max(0, val);
+
+ if (field.combCells > 0) {
+ propValue.maxLength = field.combCells;
+ propMaxLength.value = field.combCells.toString();
+ propMaxLength.disabled = true;
+ field.maxLength = field.combCells;
+
+ if (field.defaultValue.length > field.combCells) {
+ field.defaultValue = field.defaultValue.substring(0, field.combCells);
+ propValue.value = field.defaultValue;
+ }
+ } else {
+ propMaxLength.disabled = false;
+ propValue.removeAttribute('maxLength');
+ if (field.maxLength > 0) {
+ propValue.maxLength = field.maxLength;
+ }
+ }
+
+ // Re-render field visual only, NOT the properties panel
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ // Update text content
+ const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
+ if (textEl) {
+ textEl.textContent = field.defaultValue;
+ if (field.combCells > 0) {
+ textEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))`;
+ textEl.style.fontFamily = 'monospace';
+ textEl.style.letterSpacing = `calc(${field.width / field.combCells}px - 1ch)`;
+ textEl.style.paddingLeft = `calc((${field.width / field.combCells}px - 1ch) / 2)`;
+ textEl.style.overflow = 'hidden';
+ textEl.style.textAlign = 'left';
+ textEl.style.justifyContent = 'flex-start';
+ } else {
+ textEl.style.backgroundImage = 'none';
+ textEl.style.fontFamily = 'inherit';
+ textEl.style.letterSpacing = 'normal';
+ textEl.style.textAlign = field.alignment;
+ textEl.style.justifyContent =
+ field.alignment === 'left'
+ ? 'flex-start'
+ : field.alignment === 'right'
+ ? 'flex-end'
+ : 'center';
+ }
+ }
+ }
+ });
+
+ propFontSize.addEventListener('input', (e) => {
+ field.fontSize = parseInt((e.target as HTMLInputElement).value);
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
+ if (textEl) textEl.style.fontSize = field.fontSize + 'px';
+ }
+ });
+
+ propTextColor.addEventListener('input', (e) => {
+ field.textColor = (e.target as HTMLInputElement).value;
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
+ if (textEl) textEl.style.color = field.textColor;
+ }
+ });
+
+ propAlignment.addEventListener('change', (e) => {
+ field.alignment = (e.target as HTMLSelectElement).value as
+ | 'left'
+ | 'center'
+ | 'right';
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement;
+ if (textEl) {
+ textEl.style.textAlign = field.alignment;
+ textEl.style.justifyContent =
+ field.alignment === 'left'
+ ? 'flex-start'
+ : field.alignment === 'right'
+ ? 'flex-end'
+ : 'center';
+ }
+ }
+ });
+
+ const propMultilineBtn = document.getElementById(
+ 'propMultilineBtn'
+ ) as HTMLButtonElement;
+ if (propMultilineBtn) {
+ propMultilineBtn.addEventListener('click', () => {
+ field.multiline = !field.multiline;
+
+ // Update Toggle Button UI
+ const span = propMultilineBtn.querySelector('span');
+ if (field.multiline) {
+ propMultilineBtn.classList.remove('bg-gray-500');
+ propMultilineBtn.classList.add('bg-indigo-600');
+ span?.classList.remove('translate-x-0');
+ span?.classList.add('translate-x-6');
+ } else {
+ propMultilineBtn.classList.remove('bg-indigo-600');
+ propMultilineBtn.classList.add('bg-gray-500');
+ span?.classList.remove('translate-x-6');
+ span?.classList.add('translate-x-0');
+ }
- propRequired.addEventListener('change', (e) => {
- field.required = (e.target as HTMLInputElement).checked
- })
-
- propReadOnly.addEventListener('change', (e) => {
- field.readOnly = (e.target as HTMLInputElement).checked
- })
-
- const propBorderColor = document.getElementById('propBorderColor') as HTMLInputElement
- const propHideBorder = document.getElementById('propHideBorder') as HTMLInputElement
-
- propBorderColor.addEventListener('input', (e) => {
- field.borderColor = (e.target as HTMLInputElement).value
- })
-
- propHideBorder.addEventListener('change', (e) => {
- field.hideBorder = (e.target as HTMLInputElement).checked
- })
-
- deleteBtn.addEventListener('click', () => {
- deleteField(field)
- })
-
- // Specific listeners
- if (field.type === 'text') {
- const propValue = document.getElementById('propValue') as HTMLInputElement
- const propMaxLength = document.getElementById('propMaxLength') as HTMLInputElement
- const propComb = document.getElementById('propComb') as HTMLInputElement
- const propFontSize = document.getElementById('propFontSize') as HTMLInputElement
- const propTextColor = document.getElementById('propTextColor') as HTMLInputElement
- const propAlignment = document.getElementById('propAlignment') as HTMLSelectElement
-
- propValue.addEventListener('input', (e) => {
- field.defaultValue = (e.target as HTMLInputElement).value
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement
- if (textEl) textEl.textContent = field.defaultValue
- }
- })
-
- propMaxLength.addEventListener('input', (e) => {
- const val = parseInt((e.target as HTMLInputElement).value)
- field.maxLength = isNaN(val) ? 0 : Math.max(0, val)
- if (field.maxLength > 0) {
- propValue.maxLength = field.maxLength
- if (field.defaultValue.length > field.maxLength) {
- field.defaultValue = field.defaultValue.substring(0, field.maxLength)
- propValue.value = field.defaultValue
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement
- if (textEl) textEl.textContent = field.defaultValue
- }
- }
- } else {
- propValue.removeAttribute('maxLength')
- }
- })
-
- propComb.addEventListener('input', (e) => {
- const val = parseInt((e.target as HTMLInputElement).value)
- field.combCells = isNaN(val) ? 0 : Math.max(0, val)
-
- if (field.combCells > 0) {
- propValue.maxLength = field.combCells
- propMaxLength.value = field.combCells.toString()
- propMaxLength.disabled = true
- field.maxLength = field.combCells
-
- if (field.defaultValue.length > field.combCells) {
- field.defaultValue = field.defaultValue.substring(0, field.combCells)
- propValue.value = field.defaultValue
- }
+ // Update Canvas UI
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const textEl = fieldWrapper.querySelector(
+ '.field-text'
+ ) as HTMLElement;
+ if (textEl) {
+ if (field.multiline) {
+ textEl.style.whiteSpace = 'pre-wrap';
+ textEl.style.alignItems = 'flex-start';
+ textEl.style.overflow = 'hidden';
} else {
- propMaxLength.disabled = false
- propValue.removeAttribute('maxLength')
- if (field.maxLength > 0) {
- propValue.maxLength = field.maxLength
- }
- }
-
- // Re-render field visual only, NOT the properties panel
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- // Update text content
- const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement
- if (textEl) {
- textEl.textContent = field.defaultValue
- if (field.combCells > 0) {
- textEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))`
- textEl.style.fontFamily = 'monospace'
- textEl.style.letterSpacing = `calc(${field.width / field.combCells}px - 1ch)`
- textEl.style.paddingLeft = `calc((${field.width / field.combCells}px - 1ch) / 2)`
- textEl.style.overflow = 'hidden'
- textEl.style.textAlign = 'left'
- textEl.style.justifyContent = 'flex-start'
- } else {
- textEl.style.backgroundImage = 'none'
- textEl.style.fontFamily = 'inherit'
- textEl.style.letterSpacing = 'normal'
- textEl.style.textAlign = field.alignment
- textEl.style.justifyContent = field.alignment === 'left' ? 'flex-start' : field.alignment === 'right' ? 'flex-end' : 'center'
- }
- }
- }
- })
-
- propFontSize.addEventListener('input', (e) => {
- field.fontSize = parseInt((e.target as HTMLInputElement).value)
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement
- if (textEl) textEl.style.fontSize = field.fontSize + 'px'
- }
- })
-
- propTextColor.addEventListener('input', (e) => {
- field.textColor = (e.target as HTMLInputElement).value
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement
- if (textEl) textEl.style.color = field.textColor
- }
- })
-
- propAlignment.addEventListener('change', (e) => {
- field.alignment = (e.target as HTMLSelectElement).value as 'left' | 'center' | 'right'
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement
- if (textEl) {
- textEl.style.textAlign = field.alignment
- textEl.style.justifyContent = field.alignment === 'left' ? 'flex-start' : field.alignment === 'right' ? 'flex-end' : 'center'
- }
+ textEl.style.whiteSpace = 'nowrap';
+ textEl.style.alignItems = 'center';
+ textEl.style.overflow = 'hidden';
}
- })
-
- const propMultilineBtn = document.getElementById('propMultilineBtn') as HTMLButtonElement
- if (propMultilineBtn) {
- propMultilineBtn.addEventListener('click', () => {
- field.multiline = !field.multiline
-
- // Update Toggle Button UI
- const span = propMultilineBtn.querySelector('span')
- if (field.multiline) {
- propMultilineBtn.classList.remove('bg-gray-500')
- propMultilineBtn.classList.add('bg-indigo-600')
- span?.classList.remove('translate-x-0')
- span?.classList.add('translate-x-6')
- } else {
- propMultilineBtn.classList.remove('bg-indigo-600')
- propMultilineBtn.classList.add('bg-gray-500')
- span?.classList.remove('translate-x-6')
- span?.classList.add('translate-x-0')
- }
-
- // Update Canvas UI
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement
- if (textEl) {
- if (field.multiline) {
- textEl.style.whiteSpace = 'pre-wrap'
- textEl.style.alignItems = 'flex-start'
- textEl.style.overflow = 'hidden'
- } else {
- textEl.style.whiteSpace = 'nowrap'
- textEl.style.alignItems = 'center'
- textEl.style.overflow = 'hidden'
- }
- }
- }
- })
+ }
}
- } else if (field.type === 'checkbox' || field.type === 'radio') {
- const propCheckedBtn = document.getElementById('propCheckedBtn') as HTMLButtonElement
-
- propCheckedBtn.addEventListener('click', () => {
- field.checked = !field.checked
-
- // Update Toggle Button UI
- const span = propCheckedBtn.querySelector('span')
- if (field.checked) {
- propCheckedBtn.classList.remove('bg-gray-500')
- propCheckedBtn.classList.add('bg-indigo-600')
- span?.classList.remove('translate-x-0')
- span?.classList.add('translate-x-6')
- } else {
- propCheckedBtn.classList.remove('bg-indigo-600')
- propCheckedBtn.classList.add('bg-gray-500')
- span?.classList.remove('translate-x-6')
- span?.classList.add('translate-x-0')
- }
-
- // Update Canvas UI
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const contentEl = fieldWrapper.querySelector('.field-content') as HTMLElement
- if (contentEl) {
- if (field.type === 'checkbox') {
- contentEl.innerHTML = field.checked ? '
' : ''
- } else {
- contentEl.innerHTML = field.checked ? '
' : ''
- }
- }
- }
- })
-
- if (field.type === 'radio') {
- const propGroupName = document.getElementById('propGroupName') as HTMLInputElement
- const propExportValue = document.getElementById('propExportValue') as HTMLInputElement
-
- propGroupName.addEventListener('input', (e) => {
- field.groupName = (e.target as HTMLInputElement).value
- })
- propExportValue.addEventListener('input', (e) => {
- field.exportValue = (e.target as HTMLInputElement).value
- })
+ });
+ }
+ } else if (field.type === 'checkbox' || field.type === 'radio') {
+ const propCheckedBtn = document.getElementById(
+ 'propCheckedBtn'
+ ) as HTMLButtonElement;
+
+ propCheckedBtn.addEventListener('click', () => {
+ field.checked = !field.checked;
+
+ // Update Toggle Button UI
+ const span = propCheckedBtn.querySelector('span');
+ if (field.checked) {
+ propCheckedBtn.classList.remove('bg-gray-500');
+ propCheckedBtn.classList.add('bg-indigo-600');
+ span?.classList.remove('translate-x-0');
+ span?.classList.add('translate-x-6');
+ } else {
+ propCheckedBtn.classList.remove('bg-indigo-600');
+ propCheckedBtn.classList.add('bg-gray-500');
+ span?.classList.remove('translate-x-6');
+ span?.classList.add('translate-x-0');
+ }
+
+ // Update Canvas UI
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const contentEl = fieldWrapper.querySelector(
+ '.field-content'
+ ) as HTMLElement;
+ if (contentEl) {
+ if (field.type === 'checkbox') {
+ contentEl.innerHTML = field.checked
+ ? '
'
+ : '';
+ } else {
+ contentEl.innerHTML = field.checked
+ ? '
'
+ : '';
+ }
}
- } else if (field.type === 'dropdown' || field.type === 'optionlist') {
- const propOptions = document.getElementById('propOptions') as HTMLTextAreaElement
- propOptions.addEventListener('input', (e) => {
- // We split by newline OR comma for the actual options array
- const val = (e.target as HTMLTextAreaElement).value
- field.options = val.split(/[\n,]/).map(s => s.trim()).filter(s => s.length > 0)
-
- const propSelectedOption = document.getElementById('propSelectedOption') as HTMLSelectElement
- if (propSelectedOption) {
- const currentVal = field.defaultValue
- propSelectedOption.innerHTML = '
None ' +
- field.options?.map(opt => `
${opt} `).join('')
-
- if (currentVal && field.options && !field.options.includes(currentVal)) {
- field.defaultValue = ''
- propSelectedOption.value = ''
- }
- }
+ }
+ });
- renderField(field)
- })
-
- const propSelectedOption = document.getElementById('propSelectedOption') as HTMLSelectElement
- propSelectedOption.addEventListener('change', (e) => {
- field.defaultValue = (e.target as HTMLSelectElement).value
-
- // Update visual on canvas
- renderField(field)
- })
- } else if (field.type === 'button') {
- const propLabel = document.getElementById('propLabel') as HTMLInputElement
- propLabel.addEventListener('input', (e) => {
- field.label = (e.target as HTMLInputElement).value
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const contentEl = fieldWrapper.querySelector('.field-content') as HTMLElement
- if (contentEl) contentEl.textContent = field.label || 'Button'
- }
- })
-
- const propAction = document.getElementById('propAction') as HTMLSelectElement
- const propUrlContainer = document.getElementById('propUrlContainer') as HTMLDivElement
- const propJsContainer = document.getElementById('propJsContainer') as HTMLDivElement
- const propShowHideContainer = document.getElementById('propShowHideContainer') as HTMLDivElement
-
- propAction.addEventListener('change', (e) => {
- field.action = (e.target as HTMLSelectElement).value as any
-
- // Show/hide containers
- propUrlContainer.classList.add('hidden')
- propJsContainer.classList.add('hidden')
- propShowHideContainer.classList.add('hidden')
-
- if (field.action === 'url') {
- propUrlContainer.classList.remove('hidden')
- } else if (field.action === 'js') {
- propJsContainer.classList.remove('hidden')
- } else if (field.action === 'showHide') {
- propShowHideContainer.classList.remove('hidden')
- }
- })
-
- const propActionUrl = document.getElementById('propActionUrl') as HTMLInputElement
- propActionUrl.addEventListener('input', (e) => {
- field.actionUrl = (e.target as HTMLInputElement).value
- })
-
- const propJsScript = document.getElementById('propJsScript') as HTMLTextAreaElement
- if (propJsScript) {
- propJsScript.addEventListener('input', (e) => {
- field.jsScript = (e.target as HTMLTextAreaElement).value
- })
+ if (field.type === 'radio') {
+ const propGroupName = document.getElementById(
+ 'propGroupName'
+ ) as HTMLInputElement;
+ const propExportValue = document.getElementById(
+ 'propExportValue'
+ ) as HTMLInputElement;
+
+ propGroupName.addEventListener('input', (e) => {
+ field.groupName = (e.target as HTMLInputElement).value;
+ });
+ propExportValue.addEventListener('input', (e) => {
+ field.exportValue = (e.target as HTMLInputElement).value;
+ });
+ }
+ } else if (field.type === 'dropdown' || field.type === 'optionlist') {
+ const propOptions = document.getElementById(
+ 'propOptions'
+ ) as HTMLTextAreaElement;
+ propOptions.addEventListener('input', (e) => {
+ // We split by newline OR comma for the actual options array
+ const val = (e.target as HTMLTextAreaElement).value;
+ field.options = val
+ .split(/[\n,]/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+
+ const propSelectedOption = document.getElementById(
+ 'propSelectedOption'
+ ) as HTMLSelectElement;
+ if (propSelectedOption) {
+ const currentVal = field.defaultValue;
+ propSelectedOption.innerHTML =
+ '
None ' +
+ field.options
+ ?.map(
+ (opt) =>
+ `
${opt} `
+ )
+ .join('');
+
+ if (
+ currentVal &&
+ field.options &&
+ !field.options.includes(currentVal)
+ ) {
+ field.defaultValue = '';
+ propSelectedOption.value = '';
}
+ }
+
+ renderField(field);
+ });
+
+ const propSelectedOption = document.getElementById(
+ 'propSelectedOption'
+ ) as HTMLSelectElement;
+ propSelectedOption.addEventListener('change', (e) => {
+ field.defaultValue = (e.target as HTMLSelectElement).value;
+
+ // Update visual on canvas
+ renderField(field);
+ });
+ } else if (field.type === 'button') {
+ const propLabel = document.getElementById('propLabel') as HTMLInputElement;
+ propLabel.addEventListener('input', (e) => {
+ field.label = (e.target as HTMLInputElement).value;
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const contentEl = fieldWrapper.querySelector(
+ '.field-content'
+ ) as HTMLElement;
+ if (contentEl) contentEl.textContent = field.label || 'Button';
+ }
+ });
+
+ const propAction = document.getElementById(
+ 'propAction'
+ ) as HTMLSelectElement;
+ const propUrlContainer = document.getElementById(
+ 'propUrlContainer'
+ ) as HTMLDivElement;
+ const propJsContainer = document.getElementById(
+ 'propJsContainer'
+ ) as HTMLDivElement;
+ const propShowHideContainer = document.getElementById(
+ 'propShowHideContainer'
+ ) as HTMLDivElement;
+
+ propAction.addEventListener('change', (e) => {
+ field.action = (e.target as HTMLSelectElement).value as any;
+
+ // Show/hide containers
+ propUrlContainer.classList.add('hidden');
+ propJsContainer.classList.add('hidden');
+ propShowHideContainer.classList.add('hidden');
+
+ if (field.action === 'url') {
+ propUrlContainer.classList.remove('hidden');
+ } else if (field.action === 'js') {
+ propJsContainer.classList.remove('hidden');
+ } else if (field.action === 'showHide') {
+ propShowHideContainer.classList.remove('hidden');
+ }
+ });
+
+ const propActionUrl = document.getElementById(
+ 'propActionUrl'
+ ) as HTMLInputElement;
+ propActionUrl.addEventListener('input', (e) => {
+ field.actionUrl = (e.target as HTMLInputElement).value;
+ });
+
+ const propJsScript = document.getElementById(
+ 'propJsScript'
+ ) as HTMLTextAreaElement;
+ if (propJsScript) {
+ propJsScript.addEventListener('input', (e) => {
+ field.jsScript = (e.target as HTMLTextAreaElement).value;
+ });
+ }
- const propTargetField = document.getElementById('propTargetField') as HTMLSelectElement
- if (propTargetField) {
- propTargetField.addEventListener('change', (e) => {
- field.targetFieldName = (e.target as HTMLSelectElement).value
- })
- }
+ const propTargetField = document.getElementById(
+ 'propTargetField'
+ ) as HTMLSelectElement;
+ if (propTargetField) {
+ propTargetField.addEventListener('change', (e) => {
+ field.targetFieldName = (e.target as HTMLSelectElement).value;
+ });
+ }
- const propVisibilityAction = document.getElementById('propVisibilityAction') as HTMLSelectElement
- if (propVisibilityAction) {
- propVisibilityAction.addEventListener('change', (e) => {
- field.visibilityAction = (e.target as HTMLSelectElement).value as any
- })
+ const propVisibilityAction = document.getElementById(
+ 'propVisibilityAction'
+ ) as HTMLSelectElement;
+ if (propVisibilityAction) {
+ propVisibilityAction.addEventListener('change', (e) => {
+ field.visibilityAction = (e.target as HTMLSelectElement).value as any;
+ });
+ }
+ } else if (field.type === 'signature') {
+ // No specific listeners for signature fields yet
+ } else if (field.type === 'date') {
+ const propDateFormat = document.getElementById(
+ 'propDateFormat'
+ ) as HTMLSelectElement;
+ const customFormatContainer = document.getElementById(
+ 'customFormatContainer'
+ ) as HTMLDivElement;
+ const propCustomFormat = document.getElementById(
+ 'propCustomFormat'
+ ) as HTMLInputElement;
+ const dateFormatExample = document.getElementById(
+ 'dateFormatExample'
+ ) as HTMLSpanElement;
+
+ const formatDateExample = (format: string): string => {
+ const now = new Date();
+ const d = now.getDate();
+ const dd = d.toString().padStart(2, '0');
+ const m = now.getMonth() + 1;
+ const mm = m.toString().padStart(2, '0');
+ const yy = now.getFullYear().toString().slice(-2);
+ const yyyy = now.getFullYear().toString();
+ const h = now.getHours() % 12 || 12;
+ const HH = now.getHours().toString().padStart(2, '0');
+ const MM = now.getMinutes().toString().padStart(2, '0');
+ const tt = now.getHours() >= 12 ? 'PM' : 'AM';
+ const monthNames = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+ const monthNamesFull = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+ ];
+ const mmm = monthNames[now.getMonth()];
+ const mmmm = monthNamesFull[now.getMonth()];
+
+ return format
+ .replace(/mmmm/g, mmmm)
+ .replace(/mmm/g, mmm)
+ .replace(/mm/g, mm)
+ .replace(/m/g, m.toString())
+ .replace(/dddd/g, dd)
+ .replace(/dd/g, dd)
+ .replace(/d/g, d.toString())
+ .replace(/yyyy/g, yyyy)
+ .replace(/yy/g, yy)
+ .replace(/HH/g, HH)
+ .replace(/h/g, h.toString())
+ .replace(/MM/g, MM)
+ .replace(/tt/g, tt);
+ };
+
+ const updateExample = () => {
+ if (dateFormatExample) {
+ dateFormatExample.textContent = formatDateExample(
+ field.dateFormat || 'mm/dd/yyyy'
+ );
+ }
+ };
+
+ updateExample();
+
+ if (propDateFormat) {
+ propDateFormat.addEventListener('change', (e) => {
+ const value = (e.target as HTMLSelectElement).value;
+ if (value === 'custom') {
+ customFormatContainer?.classList.remove('hidden');
+ if (propCustomFormat && propCustomFormat.value) {
+ field.dateFormat = propCustomFormat.value;
+ }
+ } else {
+ customFormatContainer?.classList.add('hidden');
+ field.dateFormat = value;
}
- } else if (field.type === 'signature') {
- // No specific listeners for signature fields yet
- } else if (field.type === 'date') {
- const propDateFormat = document.getElementById('propDateFormat') as HTMLSelectElement
- if (propDateFormat) {
- propDateFormat.addEventListener('change', (e) => {
- field.dateFormat = (e.target as HTMLSelectElement).value
- // Update canvas preview
- const fieldWrapper = document.getElementById(field.id)
- if (fieldWrapper) {
- const textSpan = fieldWrapper.querySelector('.date-format-text') as HTMLElement
- if (textSpan) {
- textSpan.textContent = field.dateFormat
- }
- }
- // Re-initialize lucide icons in the properties panel
- setTimeout(() => (window as any).lucide?.createIcons(), 0)
- })
+ updateExample();
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const textSpan = fieldWrapper.querySelector(
+ '.date-format-text'
+ ) as HTMLElement;
+ if (textSpan) {
+ textSpan.textContent = field.dateFormat;
+ }
}
- } else if (field.type === 'image') {
- const propLabel = document.getElementById('propLabel') as HTMLInputElement
- propLabel.addEventListener('input', (e) => {
- field.label = (e.target as HTMLInputElement).value
- renderField(field)
- })
+ setTimeout(() => (window as any).lucide?.createIcons(), 0);
+ });
}
+
+ if (propCustomFormat) {
+ propCustomFormat.addEventListener('input', (e) => {
+ field.dateFormat = (e.target as HTMLInputElement).value || 'mm/dd/yyyy';
+ updateExample();
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const textSpan = fieldWrapper.querySelector(
+ '.date-format-text'
+ ) as HTMLElement;
+ if (textSpan) {
+ textSpan.textContent = field.dateFormat;
+ }
+ }
+ });
+ }
+ } else if (field.type === 'image') {
+ const propLabel = document.getElementById('propLabel') as HTMLInputElement;
+ propLabel.addEventListener('input', (e) => {
+ field.label = (e.target as HTMLInputElement).value;
+ renderField(field);
+ });
+ }
}
// Hide properties panel
function hideProperties(): void {
- propertiesPanel.innerHTML = '
Select a field to edit properties
'
+ propertiesPanel.innerHTML =
+ '
Select a field to edit properties
';
}
// Delete field
function deleteField(field: FormField): void {
- const fieldEl = document.getElementById(field.id)
- if (fieldEl) {
- fieldEl.remove()
- }
- fields = fields.filter((f) => f.id !== field.id)
- deselectAll()
- updateFieldCount()
+ const fieldEl = document.getElementById(field.id);
+ if (fieldEl) {
+ fieldEl.remove();
+ }
+ fields = fields.filter((f) => f.id !== field.id);
+ deselectAll();
+ updateFieldCount();
}
// Delete key handler
document.addEventListener('keydown', (e) => {
- if (e.key === 'Delete' && selectedField) {
- deleteField(selectedField)
- } else if (e.key === 'Escape' && selectedToolType) {
- // Cancel tool selection
- toolItems.forEach(item => item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600'))
- selectedToolType = null
- canvas.style.cursor = 'default'
- }
-})
+ if (e.key === 'Delete' && selectedField) {
+ deleteField(selectedField);
+ } else if (e.key === 'Escape' && selectedToolType) {
+ // Cancel tool selection
+ toolItems.forEach((item) =>
+ item.classList.remove('ring-2', 'ring-indigo-400', 'bg-indigo-600')
+ );
+ selectedToolType = null;
+ canvas.style.cursor = 'default';
+ }
+});
// Update field count
function updateFieldCount(): void {
- fieldCountDisplay.textContent = fields.length.toString()
+ fieldCountDisplay.textContent = fields.length.toString();
}
// Download PDF
downloadBtn.addEventListener('click', async () => {
- // Check for duplicate field names before generating PDF
- const nameCount = new Map
()
- const duplicates: string[] = []
- const conflictsWithPdf: string[] = []
-
- fields.forEach(field => {
- const count = nameCount.get(field.name) || 0
- nameCount.set(field.name, count + 1)
-
- if (existingFieldNames.has(field.name)) {
- if (field.type === 'radio' && existingRadioGroups.has(field.name)) {
- } else {
- conflictsWithPdf.push(field.name)
- }
- }
- })
-
- nameCount.forEach((count, name) => {
- if (count > 1) {
- const fieldsWithName = fields.filter(f => f.name === name)
- const allRadio = fieldsWithName.every(f => f.type === 'radio')
-
- if (!allRadio) {
- duplicates.push(name)
- }
- }
- })
-
- if (conflictsWithPdf.length > 0) {
- const conflictList = [...new Set(conflictsWithPdf)].map(name => `"${name}"`).join(', ')
- showModal(
- 'Field Name Conflict',
- `The following field names already exist in the uploaded PDF: ${conflictList}. Please rename these fields before downloading.`,
- 'error'
- )
- return
+ // Check for duplicate field names before generating PDF
+ const nameCount = new Map();
+ const duplicates: string[] = [];
+ const conflictsWithPdf: string[] = [];
+
+ fields.forEach((field) => {
+ const count = nameCount.get(field.name) || 0;
+ nameCount.set(field.name, count + 1);
+
+ if (existingFieldNames.has(field.name)) {
+ if (field.type === 'radio' && existingRadioGroups.has(field.name)) {
+ } else {
+ conflictsWithPdf.push(field.name);
+ }
}
+ });
- if (duplicates.length > 0) {
- const duplicateList = duplicates.map(name => `"${name}"`).join(', ')
- showModal(
- 'Duplicate Field Names',
- `The following field names are used more than once: ${duplicateList}. Please rename these fields to use unique names before downloading.`,
- 'error'
- )
- return
- }
+ nameCount.forEach((count, name) => {
+ if (count > 1) {
+ const fieldsWithName = fields.filter((f) => f.name === name);
+ const allRadio = fieldsWithName.every((f) => f.type === 'radio');
- if (fields.length === 0) {
- alert('Please add at least one field before downloading.')
- return
+ if (!allRadio) {
+ duplicates.push(name);
+ }
}
+ });
+
+ if (conflictsWithPdf.length > 0) {
+ const conflictList = [...new Set(conflictsWithPdf)]
+ .map((name) => `"${name}"`)
+ .join(', ');
+ showModal(
+ 'Field Name Conflict',
+ `The following field names already exist in the uploaded PDF: ${conflictList}. Please rename these fields before downloading.`,
+ 'error'
+ );
+ return;
+ }
+
+ if (duplicates.length > 0) {
+ const duplicateList = duplicates.map((name) => `"${name}"`).join(', ');
+ showModal(
+ 'Duplicate Field Names',
+ `The following field names are used more than once: ${duplicateList}. Please rename these fields to use unique names before downloading.`,
+ 'error'
+ );
+ return;
+ }
+
+ if (fields.length === 0) {
+ alert('Please add at least one field before downloading.');
+ return;
+ }
+
+ if (pages.length === 0) {
+ alert('No pages found. Please create a blank PDF or upload one.');
+ return;
+ }
+
+ try {
+ let pdfDoc: PDFDocument;
- if (pages.length === 0) {
- alert('No pages found. Please create a blank PDF or upload one.')
- return
- }
+ if (uploadedPdfDoc) {
+ pdfDoc = uploadedPdfDoc;
+ } else {
+ pdfDoc = await PDFDocument.create();
- try {
- let pdfDoc: PDFDocument
+ for (const pageData of pages) {
+ pdfDoc.addPage([pageData.width, pageData.height]);
+ }
+ }
- if (uploadedPdfDoc) {
- pdfDoc = uploadedPdfDoc
+ const form = pdfDoc.getForm();
+
+ const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
+
+ // Set document metadata for accessibility
+ pdfDoc.setTitle('Fillable Form');
+ pdfDoc.setAuthor('BentoPDF');
+ pdfDoc.setLanguage('en-US');
+
+ const radioGroups = new Map(); // Track created radio groups
+
+ for (const field of fields) {
+ const pageData = pages[field.pageIndex];
+ if (!pageData) continue;
+
+ const pdfPage = pdfDoc.getPage(field.pageIndex);
+ const { height: pageHeight } = pdfPage.getSize();
+
+ const scaleX = 1 / pdfViewerScale;
+ const scaleY = 1 / pdfViewerScale;
+
+ const adjustedX = field.x - pdfViewerOffset.x;
+ const adjustedY = field.y - pdfViewerOffset.y;
+
+ const x = adjustedX * scaleX;
+ const y = pageHeight - adjustedY * scaleY - field.height * scaleY;
+ const width = field.width * scaleX;
+ const height = field.height * scaleY;
+
+ console.log(`Field "${field.name}":`, {
+ screenPos: { x: field.x, y: field.y },
+ adjustedPos: { x: adjustedX, y: adjustedY },
+ pdfPos: { x, y, width, height },
+ metrics: { offset: pdfViewerOffset, scale: pdfViewerScale },
+ });
+
+ if (field.type === 'text') {
+ const textField = form.createTextField(field.name);
+ const rgbColor = hexToRgb(field.textColor);
+ const borderRgb = hexToRgb(field.borderColor || '#000000');
+
+ textField.addToPage(pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: field.hideBorder ? 0 : 1,
+ borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
+ backgroundColor: rgb(1, 1, 1),
+ textColor: rgb(rgbColor.r, rgbColor.g, rgbColor.b),
+ });
+
+ textField.setText(field.defaultValue);
+ textField.setFontSize(field.fontSize);
+
+ // Set alignment
+ if (field.alignment === 'center') {
+ textField.setAlignment(TextAlignment.Center);
+ } else if (field.alignment === 'right') {
+ textField.setAlignment(TextAlignment.Right);
} else {
- pdfDoc = await PDFDocument.create()
-
- for (const pageData of pages) {
- pdfDoc.addPage([pageData.width, pageData.height])
- }
+ textField.setAlignment(TextAlignment.Left);
}
- const form = pdfDoc.getForm()
-
- const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)
-
- // Set document metadata for accessibility
- pdfDoc.setTitle('Fillable Form')
- pdfDoc.setAuthor('BentoPDF')
- pdfDoc.setLanguage('en-US')
-
- const radioGroups = new Map() // Track created radio groups
-
- for (const field of fields) {
- const pageData = pages[field.pageIndex]
- if (!pageData) continue
-
- const pdfPage = pdfDoc.getPage(field.pageIndex)
- const { height: pageHeight } = pdfPage.getSize()
-
- const scaleX = 1 / pdfViewerScale
- const scaleY = 1 / pdfViewerScale
-
- const adjustedX = field.x - pdfViewerOffset.x
- const adjustedY = field.y - pdfViewerOffset.y
-
- const x = adjustedX * scaleX
- const y = pageHeight - (adjustedY * scaleY) - (field.height * scaleY)
- const width = field.width * scaleX
- const height = field.height * scaleY
-
- console.log(`Field "${field.name}":`, {
- screenPos: { x: field.x, y: field.y },
- adjustedPos: { x: adjustedX, y: adjustedY },
- pdfPos: { x, y, width, height },
- metrics: { offset: pdfViewerOffset, scale: pdfViewerScale }
- })
-
- if (field.type === 'text') {
- const textField = form.createTextField(field.name)
- const rgbColor = hexToRgb(field.textColor)
- const borderRgb = hexToRgb(field.borderColor || '#000000')
-
- textField.addToPage(pdfPage, {
- x: x,
- y: y,
- width: width,
- height: height,
- borderWidth: field.hideBorder ? 0 : 1,
- borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
- backgroundColor: rgb(1, 1, 1),
- textColor: rgb(rgbColor.r, rgbColor.g, rgbColor.b),
- })
-
- textField.setText(field.defaultValue)
- textField.setFontSize(field.fontSize)
-
- // Set alignment
- if (field.alignment === 'center') {
- textField.setAlignment(TextAlignment.Center)
- } else if (field.alignment === 'right') {
- textField.setAlignment(TextAlignment.Right)
- } else {
- textField.setAlignment(TextAlignment.Left)
- }
-
- // Handle combing
- if (field.combCells > 0) {
- textField.setMaxLength(field.combCells)
- textField.enableCombing()
- } else if (field.maxLength > 0) {
- textField.setMaxLength(field.maxLength)
- }
-
- // Disable multiline to prevent RTL issues (unless explicitly enabled)
- if (!field.multiline) {
- textField.disableMultiline()
- } else {
- textField.enableMultiline()
- }
-
- // Common properties
- if (field.required) textField.enableRequired()
- if (field.readOnly) textField.enableReadOnly()
- if (field.tooltip) {
- textField.acroField.getWidgets().forEach(widget => {
- widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip))
- })
- }
-
- } else if (field.type === 'checkbox') {
- const checkBox = form.createCheckBox(field.name)
- const borderRgb = hexToRgb(field.borderColor || '#000000')
- checkBox.addToPage(pdfPage, {
- x: x,
- y: y,
- width: width,
- height: height,
- borderWidth: field.hideBorder ? 0 : 1,
- borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
- backgroundColor: rgb(1, 1, 1),
- })
- if (field.checked) checkBox.check()
- if (field.required) checkBox.enableRequired()
- if (field.readOnly) checkBox.enableReadOnly()
- if (field.tooltip) {
- checkBox.acroField.getWidgets().forEach(widget => {
- widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip))
- })
- }
-
- } else if (field.type === 'radio') {
- const groupName = field.name
- let radioGroup
-
- if (radioGroups.has(groupName)) {
- radioGroup = radioGroups.get(groupName)
- } else {
- const existingField = form.getFieldMaybe(groupName)
-
- if (existingField) {
- radioGroup = existingField
- radioGroups.set(groupName, radioGroup)
- console.log(`Using existing radio group from PDF: ${groupName}`)
- } else {
- radioGroup = form.createRadioGroup(groupName)
- radioGroups.set(groupName, radioGroup)
- console.log(`Created new radio group: ${groupName}`)
- }
- }
-
- const borderRgb = hexToRgb(field.borderColor || '#000000')
- radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, {
- x: x,
- y: y,
- width: width,
- height: height,
- borderWidth: field.hideBorder ? 0 : 1,
- borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
- backgroundColor: rgb(1, 1, 1),
- })
- if (field.checked) radioGroup.select(field.exportValue || 'Yes')
- if (field.required) radioGroup.enableRequired()
- if (field.readOnly) radioGroup.enableReadOnly()
- if (field.tooltip) {
- radioGroup.acroField.getWidgets().forEach((widget: any) => {
- widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip))
- })
- }
-
- } else if (field.type === 'dropdown') {
- const dropdown = form.createDropdown(field.name)
- const borderRgb = hexToRgb(field.borderColor || '#000000')
- dropdown.addToPage(pdfPage, {
- x: x,
- y: y,
- width: width,
- height: height,
- borderWidth: field.hideBorder ? 0 : 1,
- borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
- backgroundColor: rgb(1, 1, 1), // Light blue not supported in standard PDF appearance easily without streams
- })
- if (field.options) dropdown.setOptions(field.options)
- if (field.defaultValue && field.options?.includes(field.defaultValue)) dropdown.select(field.defaultValue)
- else if (field.options && field.options.length > 0) dropdown.select(field.options[0])
-
- const rgbColor = hexToRgb(field.textColor)
- dropdown.acroField.setFontSize(field.fontSize)
- dropdown.acroField.setDefaultAppearance(
- `0 0 0 rg /Helv ${field.fontSize} Tf`
- )
-
- if (field.required) dropdown.enableRequired()
- if (field.readOnly) dropdown.enableReadOnly()
- if (field.tooltip) {
- dropdown.acroField.getWidgets().forEach(widget => {
- widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip))
- })
- }
-
- } else if (field.type === 'optionlist') {
- const optionList = form.createOptionList(field.name)
- const borderRgb = hexToRgb(field.borderColor || '#000000')
- optionList.addToPage(pdfPage, {
- x: x,
- y: y,
- width: width,
- height: height,
- borderWidth: field.hideBorder ? 0 : 1,
- borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
- backgroundColor: rgb(1, 1, 1),
- })
- if (field.options) optionList.setOptions(field.options)
- if (field.defaultValue && field.options?.includes(field.defaultValue)) optionList.select(field.defaultValue)
- else if (field.options && field.options.length > 0) optionList.select(field.options[0])
-
- const rgbColor = hexToRgb(field.textColor)
- optionList.acroField.setFontSize(field.fontSize)
- optionList.acroField.setDefaultAppearance(
- `0 0 0 rg /Helv ${field.fontSize} Tf`
- )
-
- if (field.required) optionList.enableRequired()
- if (field.readOnly) optionList.enableReadOnly()
- if (field.tooltip) {
- optionList.acroField.getWidgets().forEach(widget => {
- widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip))
- })
- }
+ // Handle combing
+ if (field.combCells > 0) {
+ textField.setMaxLength(field.combCells);
+ textField.enableCombing();
+ } else if (field.maxLength > 0) {
+ textField.setMaxLength(field.maxLength);
+ }
- } else if (field.type === 'button') {
- const button = form.createButton(field.name)
- const borderRgb = hexToRgb(field.borderColor || '#000000')
- button.addToPage(field.label || 'Button', pdfPage, {
- x: x,
- y: y,
- width: width,
- height: height,
- borderWidth: field.hideBorder ? 0 : 1,
- borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
- backgroundColor: rgb(0.8, 0.8, 0.8), // Light gray
- })
-
- // Add Action
- if (field.action && field.action !== 'none') {
- const widgets = button.acroField.getWidgets()
-
- widgets.forEach(widget => {
- let actionDict: any
-
- if (field.action === 'reset') {
- actionDict = pdfDoc.context.obj({
- Type: 'Action',
- S: 'ResetForm'
- })
- } else if (field.action === 'print') {
- // Print action using JavaScript
- actionDict = pdfDoc.context.obj({
- Type: 'Action',
- S: 'JavaScript',
- JS: 'print();'
- })
- } else if (field.action === 'url' && field.actionUrl) {
- // Validate URL
- let url = field.actionUrl.trim()
- if (!url.startsWith('http://') && !url.startsWith('https://')) {
- url = 'https://' + url
- }
+ // Disable multiline to prevent RTL issues (unless explicitly enabled)
+ if (!field.multiline) {
+ textField.disableMultiline();
+ } else {
+ textField.enableMultiline();
+ }
- // Encode URL to handle special characters (RFC3986)
- try {
- url = encodeURI(url)
- } catch (e) {
- console.warn('Failed to encode URL:', e)
- }
+ // Common properties
+ if (field.required) textField.enableRequired();
+ if (field.readOnly) textField.enableReadOnly();
+ if (field.tooltip) {
+ textField.acroField.getWidgets().forEach((widget) => {
+ widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
+ });
+ }
+ } else if (field.type === 'checkbox') {
+ const checkBox = form.createCheckBox(field.name);
+ const borderRgb = hexToRgb(field.borderColor || '#000000');
+ checkBox.addToPage(pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: field.hideBorder ? 0 : 1,
+ borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
+ backgroundColor: rgb(1, 1, 1),
+ });
+ if (field.checked) checkBox.check();
+ if (field.required) checkBox.enableRequired();
+ if (field.readOnly) checkBox.enableReadOnly();
+ if (field.tooltip) {
+ checkBox.acroField.getWidgets().forEach((widget) => {
+ widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
+ });
+ }
+ } else if (field.type === 'radio') {
+ const groupName = field.name;
+ let radioGroup;
- actionDict = pdfDoc.context.obj({
- Type: 'Action',
- S: 'URI',
- URI: PDFString.of(url)
- })
- } else if (field.action === 'js' && field.jsScript) {
- actionDict = pdfDoc.context.obj({
- Type: 'Action',
- S: 'JavaScript',
- JS: field.jsScript
- })
- } else if (field.action === 'showHide' && field.targetFieldName) {
- const target = field.targetFieldName
- let script = ''
-
- if (field.visibilityAction === 'show') {
- script = `var f = this.getField("${target}"); if(f) f.display = display.visible;`
- } else if (field.visibilityAction === 'hide') {
- script = `var f = this.getField("${target}"); if(f) f.display = display.hidden;`
- } else {
- // Toggle
- script = `var f = this.getField("${target}"); if(f) f.display = (f.display === display.visible) ? display.hidden : display.visible;`
- }
+ if (radioGroups.has(groupName)) {
+ radioGroup = radioGroups.get(groupName);
+ } else {
+ const existingField = form.getFieldMaybe(groupName);
+
+ if (existingField) {
+ radioGroup = existingField;
+ radioGroups.set(groupName, radioGroup);
+ console.log(`Using existing radio group from PDF: ${groupName}`);
+ } else {
+ radioGroup = form.createRadioGroup(groupName);
+ radioGroups.set(groupName, radioGroup);
+ console.log(`Created new radio group: ${groupName}`);
+ }
+ }
- actionDict = pdfDoc.context.obj({
- Type: 'Action',
- S: 'JavaScript',
- JS: script
- })
- }
-
- if (actionDict) {
- widget.dict.set(PDFName.of('A'), actionDict)
- }
- })
- }
+ const borderRgb = hexToRgb(field.borderColor || '#000000');
+ radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: field.hideBorder ? 0 : 1,
+ borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
+ backgroundColor: rgb(1, 1, 1),
+ });
+ if (field.checked) radioGroup.select(field.exportValue || 'Yes');
+ if (field.required) radioGroup.enableRequired();
+ if (field.readOnly) radioGroup.enableReadOnly();
+ if (field.tooltip) {
+ radioGroup.acroField.getWidgets().forEach((widget: any) => {
+ widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
+ });
+ }
+ } else if (field.type === 'dropdown') {
+ const dropdown = form.createDropdown(field.name);
+ const borderRgb = hexToRgb(field.borderColor || '#000000');
+ dropdown.addToPage(pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: field.hideBorder ? 0 : 1,
+ borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
+ backgroundColor: rgb(1, 1, 1), // Light blue not supported in standard PDF appearance easily without streams
+ });
+ if (field.options) dropdown.setOptions(field.options);
+ if (field.defaultValue && field.options?.includes(field.defaultValue))
+ dropdown.select(field.defaultValue);
+ else if (field.options && field.options.length > 0)
+ dropdown.select(field.options[0]);
+
+ const rgbColor = hexToRgb(field.textColor);
+ dropdown.acroField.setFontSize(field.fontSize);
+ dropdown.acroField.setDefaultAppearance(
+ `0 0 0 rg /Helv ${field.fontSize} Tf`
+ );
+
+ if (field.required) dropdown.enableRequired();
+ if (field.readOnly) dropdown.enableReadOnly();
+ if (field.tooltip) {
+ dropdown.acroField.getWidgets().forEach((widget) => {
+ widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
+ });
+ }
+ } else if (field.type === 'optionlist') {
+ const optionList = form.createOptionList(field.name);
+ const borderRgb = hexToRgb(field.borderColor || '#000000');
+ optionList.addToPage(pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: field.hideBorder ? 0 : 1,
+ borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
+ backgroundColor: rgb(1, 1, 1),
+ });
+ if (field.options) optionList.setOptions(field.options);
+ if (field.defaultValue && field.options?.includes(field.defaultValue))
+ optionList.select(field.defaultValue);
+ else if (field.options && field.options.length > 0)
+ optionList.select(field.options[0]);
+
+ const rgbColor = hexToRgb(field.textColor);
+ optionList.acroField.setFontSize(field.fontSize);
+ optionList.acroField.setDefaultAppearance(
+ `0 0 0 rg /Helv ${field.fontSize} Tf`
+ );
+
+ if (field.required) optionList.enableRequired();
+ if (field.readOnly) optionList.enableReadOnly();
+ if (field.tooltip) {
+ optionList.acroField.getWidgets().forEach((widget) => {
+ widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
+ });
+ }
+ } else if (field.type === 'button') {
+ const button = form.createButton(field.name);
+ const borderRgb = hexToRgb(field.borderColor || '#000000');
+ button.addToPage(field.label || 'Button', pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: field.hideBorder ? 0 : 1,
+ borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
+ backgroundColor: rgb(0.8, 0.8, 0.8), // Light gray
+ });
+
+ // Add Action
+ if (field.action && field.action !== 'none') {
+ const widgets = button.acroField.getWidgets();
+
+ widgets.forEach((widget) => {
+ let actionDict: any;
+
+ if (field.action === 'reset') {
+ actionDict = pdfDoc.context.obj({
+ Type: 'Action',
+ S: 'ResetForm',
+ });
+ } else if (field.action === 'print') {
+ // Print action using JavaScript
+ actionDict = pdfDoc.context.obj({
+ Type: 'Action',
+ S: 'JavaScript',
+ JS: 'print();',
+ });
+ } else if (field.action === 'url' && field.actionUrl) {
+ // Validate URL
+ let url = field.actionUrl.trim();
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
+ url = 'https://' + url;
+ }
+
+ // Encode URL to handle special characters (RFC3986)
+ try {
+ url = encodeURI(url);
+ } catch (e) {
+ console.warn('Failed to encode URL:', e);
+ }
+
+ actionDict = pdfDoc.context.obj({
+ Type: 'Action',
+ S: 'URI',
+ URI: PDFString.of(url),
+ });
+ } else if (field.action === 'js' && field.jsScript) {
+ actionDict = pdfDoc.context.obj({
+ Type: 'Action',
+ S: 'JavaScript',
+ JS: field.jsScript,
+ });
+ } else if (field.action === 'showHide' && field.targetFieldName) {
+ const target = field.targetFieldName;
+ let script = '';
+
+ if (field.visibilityAction === 'show') {
+ script = `var f = this.getField("${target}"); if(f) f.display = display.visible;`;
+ } else if (field.visibilityAction === 'hide') {
+ script = `var f = this.getField("${target}"); if(f) f.display = display.hidden;`;
+ } else {
+ // Toggle
+ script = `var f = this.getField("${target}"); if(f) f.display = (f.display === display.visible) ? display.hidden : display.visible;`;
+ }
+
+ actionDict = pdfDoc.context.obj({
+ Type: 'Action',
+ S: 'JavaScript',
+ JS: script,
+ });
+ }
- if (field.tooltip) {
- button.acroField.getWidgets().forEach(widget => {
- widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip))
- })
- }
- } else if (field.type === 'date') {
- const dateField = form.createTextField(field.name)
- dateField.addToPage(pdfPage, {
- x: x,
- y: y,
- width: width,
- height: height,
- borderWidth: 1,
- borderColor: rgb(0, 0, 0),
- backgroundColor: rgb(1, 1, 1),
- })
-
- // Add Date Format and Keystroke Actions to the FIELD (not widget)
- const dateFormat = field.dateFormat || 'mm/dd/yyyy'
-
- const formatAction = pdfDoc.context.obj({
- Type: 'Action',
- S: 'JavaScript',
- JS: PDFString.of(`AFDate_FormatEx("${dateFormat}");`)
- })
-
- const keystrokeAction = pdfDoc.context.obj({
- Type: 'Action',
- S: 'JavaScript',
- JS: PDFString.of(`AFDate_KeystrokeEx("${dateFormat}");`)
- })
-
- // Attach AA (Additional Actions) to the field dictionary
- const additionalActions = pdfDoc.context.obj({
- F: formatAction,
- K: keystrokeAction
- })
- dateField.acroField.dict.set(PDFName.of('AA'), additionalActions)
-
- if (field.required) dateField.enableRequired()
- if (field.readOnly) dateField.enableReadOnly()
- if (field.tooltip) {
- dateField.acroField.getWidgets().forEach(widget => {
- widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip))
- })
- }
- } else if (field.type === 'image') {
- const imageBtn = form.createButton(field.name)
- imageBtn.addToPage(field.label || 'Click to Upload Image', pdfPage, {
- x: x,
- y: y,
- width: width,
- height: height,
- borderWidth: 1,
- borderColor: rgb(0, 0, 0),
- backgroundColor: rgb(0.9, 0.9, 0.9),
- })
-
- // Add Import Icon Action
- const widgets = imageBtn.acroField.getWidgets()
- widgets.forEach(widget => {
- const actionDict = pdfDoc.context.obj({
- Type: 'Action',
- S: 'JavaScript',
- JS: 'event.target.buttonImportIcon();'
- })
- widget.dict.set(PDFName.of('A'), actionDict)
-
- // Set Appearance Characteristics (MK) -> Text Position (TP) = 1 (Icon Only)
- // This ensures the image replaces the text when uploaded
- // IF (Icon Fit) -> SW: A (Always Scale), S: A (Anamorphic/Fill)
- const mkDict = pdfDoc.context.obj({
- TP: 1,
- BG: [0.9, 0.9, 0.9], // Background color (Light Gray)
- BC: [0, 0, 0], // Border color (Black)
- IF: {
- SW: PDFName.of('A'),
- S: PDFName.of('A'),
- FB: true
- }
- })
- widget.dict.set(PDFName.of('MK'), mkDict)
- })
-
- if (field.tooltip) {
- imageBtn.acroField.getWidgets().forEach(widget => {
- widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip))
- })
- }
- } else if (field.type === 'signature') {
- const context = pdfDoc.context
-
- // Create the signature field dictionary with FT = Sig
- const sigDict = context.obj({
- FT: PDFName.of('Sig'),
- T: PDFString.of(field.name),
- Kids: [],
- }) as PDFDict
- const sigRef = context.register(sigDict)
-
- // Create the widget annotation for the signature field
- const widgetDict = context.obj({
- Type: PDFName.of('Annot'),
- Subtype: PDFName.of('Widget'),
- Rect: [x, y, x + width, y + height],
- F: 4, // Print flag
- P: pdfPage.ref,
- Parent: sigRef,
- }) as PDFDict
-
- // Add border and background appearance
- const borderStyle = context.obj({
- W: 1, // Border width
- S: PDFName.of('S'), // Solid border
- }) as PDFDict
- widgetDict.set(PDFName.of('BS'), borderStyle)
- widgetDict.set(PDFName.of('BC'), context.obj([0, 0, 0])) // Border color (black)
- widgetDict.set(PDFName.of('BG'), context.obj([0.95, 0.95, 0.95])) // Background color
-
- const widgetRef = context.register(widgetDict)
-
- const kidsArray = sigDict.get(PDFName.of('Kids')) as PDFArray
- kidsArray.push(widgetRef)
-
- pdfPage.node.addAnnot(widgetRef)
-
- const acroForm = form.acroForm
- acroForm.addField(sigRef)
-
- // Add tooltip if specified
- if (field.tooltip) {
- widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip))
- }
+ if (actionDict) {
+ widget.dict.set(PDFName.of('A'), actionDict);
}
+ });
}
- form.updateFieldAppearances(helveticaFont)
-
- const pdfBytes = await pdfDoc.save()
- const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' })
- downloadFile(blob, 'fillable-form.pdf')
- showModal('Success', 'Your PDF has been downloaded successfully.', 'info', () => {
- resetToInitial()
- }, 'Okay')
- } catch (error) {
- console.error('Error generating PDF:', error)
- const errorMessage = (error as Error).message
-
- // Check if it's a duplicate field name error
- if (errorMessage.includes('A field already exists with the specified name')) {
- // Extract the field name from the error message
- const match = errorMessage.match(/A field already exists with the specified name: "(.+?)"/)
- const fieldName = match ? match[1] : 'unknown'
-
- if (existingRadioGroups.has(fieldName)) {
- console.log(`Adding to existing radio group: ${fieldName}`)
- } else {
- showModal('Duplicate Field Name', `A field named "${fieldName}" already exists. Please rename this field to use a unique name before downloading.`, 'error')
- }
- } else {
- showModal('Error', 'Error generating PDF: ' + errorMessage, 'error')
+ if (field.tooltip) {
+ button.acroField.getWidgets().forEach((widget) => {
+ widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
+ });
+ }
+ } else if (field.type === 'date') {
+ const dateField = form.createTextField(field.name);
+ dateField.addToPage(pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: 1,
+ borderColor: rgb(0, 0, 0),
+ backgroundColor: rgb(1, 1, 1),
+ });
+
+ // Add Date Format and Keystroke Actions to the FIELD (not widget)
+ const dateFormat = field.dateFormat || 'mm/dd/yyyy';
+
+ const formatAction = pdfDoc.context.obj({
+ Type: 'Action',
+ S: 'JavaScript',
+ JS: PDFString.of(`AFDate_FormatEx("${dateFormat}");`),
+ });
+
+ const keystrokeAction = pdfDoc.context.obj({
+ Type: 'Action',
+ S: 'JavaScript',
+ JS: PDFString.of(`AFDate_KeystrokeEx("${dateFormat}");`),
+ });
+
+ // Attach AA (Additional Actions) to the field dictionary
+ const additionalActions = pdfDoc.context.obj({
+ F: formatAction,
+ K: keystrokeAction,
+ });
+ dateField.acroField.dict.set(PDFName.of('AA'), additionalActions);
+
+ if (field.required) dateField.enableRequired();
+ if (field.readOnly) dateField.enableReadOnly();
+ if (field.tooltip) {
+ dateField.acroField.getWidgets().forEach((widget) => {
+ widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
+ });
+ }
+ } else if (field.type === 'image') {
+ const imageBtn = form.createButton(field.name);
+ imageBtn.addToPage(field.label || 'Click to Upload Image', pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: 1,
+ borderColor: rgb(0, 0, 0),
+ backgroundColor: rgb(0.9, 0.9, 0.9),
+ });
+
+ // Add Import Icon Action
+ const widgets = imageBtn.acroField.getWidgets();
+ widgets.forEach((widget) => {
+ const actionDict = pdfDoc.context.obj({
+ Type: 'Action',
+ S: 'JavaScript',
+ JS: 'event.target.buttonImportIcon();',
+ });
+ widget.dict.set(PDFName.of('A'), actionDict);
+
+ // Set Appearance Characteristics (MK) -> Text Position (TP) = 1 (Icon Only)
+ // This ensures the image replaces the text when uploaded
+ // IF (Icon Fit) -> SW: A (Always Scale), S: A (Anamorphic/Fill)
+ const mkDict = pdfDoc.context.obj({
+ TP: 1,
+ BG: [0.9, 0.9, 0.9], // Background color (Light Gray)
+ BC: [0, 0, 0], // Border color (Black)
+ IF: {
+ SW: PDFName.of('A'),
+ S: PDFName.of('A'),
+ FB: true,
+ },
+ });
+ widget.dict.set(PDFName.of('MK'), mkDict);
+ });
+
+ if (field.tooltip) {
+ imageBtn.acroField.getWidgets().forEach((widget) => {
+ widget.dict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
+ });
}
+ } else if (field.type === 'signature') {
+ const context = pdfDoc.context;
+
+ // Create the signature field dictionary with FT = Sig
+ const sigDict = context.obj({
+ FT: PDFName.of('Sig'),
+ T: PDFString.of(field.name),
+ Kids: [],
+ }) as PDFDict;
+ const sigRef = context.register(sigDict);
+
+ // Create the widget annotation for the signature field
+ const widgetDict = context.obj({
+ Type: PDFName.of('Annot'),
+ Subtype: PDFName.of('Widget'),
+ Rect: [x, y, x + width, y + height],
+ F: 4, // Print flag
+ P: pdfPage.ref,
+ Parent: sigRef,
+ }) as PDFDict;
+
+ // Add border and background appearance
+ const borderStyle = context.obj({
+ W: 1, // Border width
+ S: PDFName.of('S'), // Solid border
+ }) as PDFDict;
+ widgetDict.set(PDFName.of('BS'), borderStyle);
+ widgetDict.set(PDFName.of('BC'), context.obj([0, 0, 0])); // Border color (black)
+ widgetDict.set(PDFName.of('BG'), context.obj([0.95, 0.95, 0.95])); // Background color
+
+ const widgetRef = context.register(widgetDict);
+
+ const kidsArray = sigDict.get(PDFName.of('Kids')) as PDFArray;
+ kidsArray.push(widgetRef);
+
+ pdfPage.node.addAnnot(widgetRef);
+
+ const acroForm = form.acroForm;
+ acroForm.addField(sigRef);
+
+ // Add tooltip if specified
+ if (field.tooltip) {
+ widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
+ }
+ }
}
-})
+
+ form.updateFieldAppearances(helveticaFont);
+
+ const pdfBytes = await pdfDoc.save();
+ const blob = new Blob([new Uint8Array(pdfBytes)], {
+ type: 'application/pdf',
+ });
+ downloadFile(blob, 'fillable-form.pdf');
+ showModal(
+ 'Success',
+ 'Your PDF has been downloaded successfully.',
+ 'info',
+ () => {
+ resetToInitial();
+ },
+ 'Okay'
+ );
+ } catch (error) {
+ console.error('Error generating PDF:', error);
+ const errorMessage = (error as Error).message;
+
+ // Check if it's a duplicate field name error
+ if (
+ errorMessage.includes('A field already exists with the specified name')
+ ) {
+ // Extract the field name from the error message
+ const match = errorMessage.match(
+ /A field already exists with the specified name: "(.+?)"/
+ );
+ const fieldName = match ? match[1] : 'unknown';
+
+ if (existingRadioGroups.has(fieldName)) {
+ console.log(`Adding to existing radio group: ${fieldName}`);
+ } else {
+ showModal(
+ 'Duplicate Field Name',
+ `A field named "${fieldName}" already exists. Please rename this field to use a unique name before downloading.`,
+ 'error'
+ );
+ }
+ } else {
+ showModal('Error', 'Error generating PDF: ' + errorMessage, 'error');
+ }
+ }
+});
// Back to tools button
-const backToToolsBtns = document.querySelectorAll('[id^="back-to-tools"]') as NodeListOf
-backToToolsBtns.forEach(btn => {
- btn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL
- })
-})
+const backToToolsBtns = document.querySelectorAll(
+ '[id^="back-to-tools"]'
+) as NodeListOf;
+backToToolsBtns.forEach((btn) => {
+ btn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+});
function getPageDimensions(size: string): { width: number; height: number } {
- let dimensions: [number, number]
- switch (size) {
- case 'letter':
- dimensions = PageSizes.Letter
- break
- case 'a4':
- dimensions = PageSizes.A4
- break
- case 'a5':
- dimensions = PageSizes.A5
- break
- case 'legal':
- dimensions = PageSizes.Legal
- break
- case 'tabloid':
- dimensions = PageSizes.Tabloid
- break
- case 'a3':
- dimensions = PageSizes.A3
- break
- case 'custom':
- // Get custom dimensions from inputs
- const width = parseInt(customWidth.value) || 612
- const height = parseInt(customHeight.value) || 792
- return { width, height }
- default:
- dimensions = PageSizes.Letter
- }
- return { width: dimensions[0], height: dimensions[1] }
+ let dimensions: [number, number];
+ switch (size) {
+ case 'letter':
+ dimensions = PageSizes.Letter;
+ break;
+ case 'a4':
+ dimensions = PageSizes.A4;
+ break;
+ case 'a5':
+ dimensions = PageSizes.A5;
+ break;
+ case 'legal':
+ dimensions = PageSizes.Legal;
+ break;
+ case 'tabloid':
+ dimensions = PageSizes.Tabloid;
+ break;
+ case 'a3':
+ dimensions = PageSizes.A3;
+ break;
+ case 'custom':
+ // Get custom dimensions from inputs
+ const width = parseInt(customWidth.value) || 612;
+ const height = parseInt(customHeight.value) || 792;
+ return { width, height };
+ default:
+ dimensions = PageSizes.Letter;
+ }
+ return { width: dimensions[0], height: dimensions[1] };
}
// Reset to initial state
function resetToInitial(): void {
- fields = []
- pages = []
- currentPageIndex = 0
- uploadedPdfDoc = null
- selectedField = null
+ fields = [];
+ pages = [];
+ currentPageIndex = 0;
+ uploadedPdfDoc = null;
+ selectedField = null;
- canvas.innerHTML = ''
+ canvas.innerHTML = '';
- propertiesPanel.innerHTML = 'Select a field to edit properties
'
+ propertiesPanel.innerHTML =
+ 'Select a field to edit properties
';
- updateFieldCount()
+ updateFieldCount();
- // Show upload area and hide tool container
- uploadArea.classList.remove('hidden')
- toolContainer.classList.add('hidden')
- pageSizeSelector.classList.add('hidden')
- setTimeout(() => createIcons({ icons }), 100)
+ // Show upload area and hide tool container
+ uploadArea.classList.remove('hidden');
+ toolContainer.classList.add('hidden');
+ pageSizeSelector.classList.add('hidden');
+ setTimeout(() => createIcons({ icons }), 100);
}
function createBlankPage(): void {
- pages.push({
- index: pages.length,
- width: pageSize.width,
- height: pageSize.height
- })
- updatePageNavigation()
+ pages.push({
+ index: pages.length,
+ width: pageSize.width,
+ height: pageSize.height,
+ });
+ updatePageNavigation();
}
function switchToPage(pageIndex: number): void {
- if (pageIndex < 0 || pageIndex >= pages.length) return
+ if (pageIndex < 0 || pageIndex >= pages.length) return;
- currentPageIndex = pageIndex
- renderCanvas()
- updatePageNavigation()
+ currentPageIndex = pageIndex;
+ renderCanvas();
+ updatePageNavigation();
- // Deselect any selected field when switching pages
- deselectAll()
+ // Deselect any selected field when switching pages
+ deselectAll();
}
// Render the canvas for the current page
async function renderCanvas(): Promise {
- const currentPage = pages[currentPageIndex]
- if (!currentPage) return
+ const currentPage = pages[currentPageIndex];
+ if (!currentPage) return;
- // Fixed scale for better visibility
- const scale = 1.333
+ // Fixed scale for better visibility
+ const scale = 1.333;
- currentScale = scale
+ currentScale = scale;
- // Use actual PDF page dimensions (not scaled)
- const canvasWidth = currentPage.width * scale
- const canvasHeight = currentPage.height * scale
+ // Use actual PDF page dimensions (not scaled)
+ const canvasWidth = currentPage.width * scale;
+ const canvasHeight = currentPage.height * scale;
- canvas.style.width = `${canvasWidth}px`
- canvas.style.height = `${canvasHeight}px`
+ canvas.style.width = `${canvasWidth}px`;
+ canvas.style.height = `${canvasHeight}px`;
- canvas.innerHTML = ''
+ canvas.innerHTML = '';
- if (uploadedPdfDoc) {
+ if (uploadedPdfDoc) {
+ try {
+ const arrayBuffer = await uploadedPdfDoc.save();
+ const blob = new Blob([arrayBuffer.buffer as ArrayBuffer], {
+ type: 'application/pdf',
+ });
+ const blobUrl = URL.createObjectURL(blob);
+
+ const iframe = document.createElement('iframe');
+ iframe.src = `${import.meta.env.BASE_URL}pdfjs-viewer/viewer.html?file=${encodeURIComponent(blobUrl)}#page=${currentPageIndex + 1}&toolbar=0`;
+ iframe.style.width = '100%';
+ iframe.style.height = `${canvasHeight}px`;
+ iframe.style.border = 'none';
+ iframe.style.position = 'absolute';
+ iframe.style.top = '0';
+ iframe.style.left = '0';
+ iframe.style.pointerEvents = 'none';
+ iframe.style.opacity = '0.8';
+
+ iframe.onload = () => {
try {
- const arrayBuffer = await uploadedPdfDoc.save()
- const blob = new Blob([arrayBuffer.buffer as ArrayBuffer], { type: 'application/pdf' })
- const blobUrl = URL.createObjectURL(blob)
-
- const iframe = document.createElement('iframe')
- iframe.src = `${import.meta.env.BASE_URL}pdfjs-viewer/viewer.html?file=${encodeURIComponent(blobUrl)}#page=${currentPageIndex + 1}&toolbar=0`
- iframe.style.width = '100%'
- iframe.style.height = `${canvasHeight}px`
- iframe.style.border = 'none'
- iframe.style.position = 'absolute'
- iframe.style.top = '0'
- iframe.style.left = '0'
- iframe.style.pointerEvents = 'none'
- iframe.style.opacity = '0.8'
-
- iframe.onload = () => {
- try {
- const viewerWindow = iframe.contentWindow as any
- if (viewerWindow && viewerWindow.PDFViewerApplication) {
- const app = viewerWindow.PDFViewerApplication
-
- const style = viewerWindow.document.createElement('style')
- style.textContent = `
+ const viewerWindow = iframe.contentWindow as any;
+ if (viewerWindow && viewerWindow.PDFViewerApplication) {
+ const app = viewerWindow.PDFViewerApplication;
+
+ const style = viewerWindow.document.createElement('style');
+ style.textContent = `
* {
margin: 0 !important;
padding: 0 !important;
@@ -2128,268 +2555,306 @@ async function renderCanvas(): Promise {
border: none !important;
box-shadow: none !important;
}
- `
- viewerWindow.document.head.appendChild(style)
-
- const checkRender = setInterval(() => {
- if (app.pdfViewer && app.pdfViewer.pagesCount > 0) {
- clearInterval(checkRender)
-
- const pageContainer = viewerWindow.document.querySelector('.page')
- if (pageContainer) {
- const initialRect = pageContainer.getBoundingClientRect()
-
- const offsetX = -initialRect.left
- const offsetY = -initialRect.top
- pageContainer.style.transform = `translate(${offsetX}px, ${offsetY}px)`
-
- setTimeout(() => {
- const rect = pageContainer.getBoundingClientRect()
- const style = viewerWindow.getComputedStyle(pageContainer)
-
- const borderLeft = parseFloat(style.borderLeftWidth) || 0
- const borderTop = parseFloat(style.borderTopWidth) || 0
- const borderRight = parseFloat(style.borderRightWidth) || 0
-
- pdfViewerOffset = {
- x: rect.left + borderLeft,
- y: rect.top + borderTop
- }
-
- const contentWidth = rect.width - borderLeft - borderRight
- pdfViewerScale = contentWidth / currentPage.width
-
- console.log('๐ Calibrated Metrics (force positioned):', {
- initialPosition: { left: initialRect.left, top: initialRect.top },
- appliedTransform: { x: offsetX, y: offsetY },
- finalRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
- computedBorders: { left: borderLeft, top: borderTop, right: borderRight },
- finalOffset: pdfViewerOffset,
- finalScale: pdfViewerScale,
- pdfDimensions: { width: currentPage.width, height: currentPage.height }
- })
- }, 50)
- }
- }
- }, 100)
- }
- } catch (e) {
- console.error('Error accessing iframe content:', e)
+ `;
+ viewerWindow.document.head.appendChild(style);
+
+ const checkRender = setInterval(() => {
+ if (app.pdfViewer && app.pdfViewer.pagesCount > 0) {
+ clearInterval(checkRender);
+
+ const pageContainer =
+ viewerWindow.document.querySelector('.page');
+ if (pageContainer) {
+ const initialRect = pageContainer.getBoundingClientRect();
+
+ const offsetX = -initialRect.left;
+ const offsetY = -initialRect.top;
+ pageContainer.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
+
+ setTimeout(() => {
+ const rect = pageContainer.getBoundingClientRect();
+ const style = viewerWindow.getComputedStyle(pageContainer);
+
+ const borderLeft = parseFloat(style.borderLeftWidth) || 0;
+ const borderTop = parseFloat(style.borderTopWidth) || 0;
+ const borderRight = parseFloat(style.borderRightWidth) || 0;
+
+ pdfViewerOffset = {
+ x: rect.left + borderLeft,
+ y: rect.top + borderTop,
+ };
+
+ const contentWidth = rect.width - borderLeft - borderRight;
+ pdfViewerScale = contentWidth / currentPage.width;
+
+ console.log('๐ Calibrated Metrics (force positioned):', {
+ initialPosition: {
+ left: initialRect.left,
+ top: initialRect.top,
+ },
+ appliedTransform: { x: offsetX, y: offsetY },
+ finalRect: {
+ left: rect.left,
+ top: rect.top,
+ width: rect.width,
+ height: rect.height,
+ },
+ computedBorders: {
+ left: borderLeft,
+ top: borderTop,
+ right: borderRight,
+ },
+ finalOffset: pdfViewerOffset,
+ finalScale: pdfViewerScale,
+ pdfDimensions: {
+ width: currentPage.width,
+ height: currentPage.height,
+ },
+ });
+ }, 50);
}
- }
-
- canvas.appendChild(iframe)
-
- console.log('Canvas dimensions:', { width: canvasWidth, height: canvasHeight, scale: currentScale })
- console.log('PDF page dimensions:', { width: currentPage.width, height: currentPage.height })
- } catch (error) {
- console.error('Error rendering PDF:', error)
+ }
+ }, 100);
+ }
+ } catch (e) {
+ console.error('Error accessing iframe content:', e);
}
+ };
+
+ canvas.appendChild(iframe);
+
+ console.log('Canvas dimensions:', {
+ width: canvasWidth,
+ height: canvasHeight,
+ scale: currentScale,
+ });
+ console.log('PDF page dimensions:', {
+ width: currentPage.width,
+ height: currentPage.height,
+ });
+ } catch (error) {
+ console.error('Error rendering PDF:', error);
}
+ }
- fields.filter(f => f.pageIndex === currentPageIndex).forEach(field => {
- renderField(field)
- })
+ fields
+ .filter((f) => f.pageIndex === currentPageIndex)
+ .forEach((field) => {
+ renderField(field);
+ });
}
function updatePageNavigation(): void {
- pageIndicator.textContent = `Page ${currentPageIndex + 1} of ${pages.length}`
- prevPageBtn.disabled = currentPageIndex === 0
- nextPageBtn.disabled = currentPageIndex === pages.length - 1
+ pageIndicator.textContent = `Page ${currentPageIndex + 1} of ${pages.length}`;
+ prevPageBtn.disabled = currentPageIndex === 0;
+ nextPageBtn.disabled = currentPageIndex === pages.length - 1;
}
// Drag and drop handlers for upload area
dropZone.addEventListener('dragover', (e) => {
- e.preventDefault()
- dropZone.classList.add('border-indigo-500', 'bg-gray-600')
-})
+ e.preventDefault();
+ dropZone.classList.add('border-indigo-500', 'bg-gray-600');
+});
dropZone.addEventListener('dragleave', () => {
- dropZone.classList.remove('border-indigo-500', 'bg-gray-600')
-})
+ dropZone.classList.remove('border-indigo-500', 'bg-gray-600');
+});
dropZone.addEventListener('drop', (e) => {
- e.preventDefault()
- dropZone.classList.remove('border-indigo-500', 'bg-gray-600')
- const files = e.dataTransfer?.files
- if (files && files.length > 0 && files[0].type === 'application/pdf') {
- handlePdfUpload(files[0])
- }
-})
+ e.preventDefault();
+ dropZone.classList.remove('border-indigo-500', 'bg-gray-600');
+ const files = e.dataTransfer?.files;
+ if (files && files.length > 0 && files[0].type === 'application/pdf') {
+ handlePdfUpload(files[0]);
+ }
+});
pdfFileInput.addEventListener('change', async (e) => {
- const file = (e.target as HTMLInputElement).files?.[0]
- if (file) {
- handlePdfUpload(file)
- }
-})
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (file) {
+ handlePdfUpload(file);
+ }
+});
blankPdfBtn.addEventListener('click', () => {
- pageSizeSelector.classList.remove('hidden')
-})
+ pageSizeSelector.classList.remove('hidden');
+});
pageSizeSelect.addEventListener('change', () => {
- if (pageSizeSelect.value === 'custom') {
- customDimensionsInput.classList.remove('hidden')
- } else {
- customDimensionsInput.classList.add('hidden')
- }
-})
+ if (pageSizeSelect.value === 'custom') {
+ customDimensionsInput.classList.remove('hidden');
+ } else {
+ customDimensionsInput.classList.add('hidden');
+ }
+});
confirmBlankBtn.addEventListener('click', () => {
- const selectedSize = pageSizeSelect.value
- pageSize = getPageDimensions(selectedSize)
+ const selectedSize = pageSizeSelect.value;
+ pageSize = getPageDimensions(selectedSize);
- createBlankPage()
- switchToPage(0)
+ createBlankPage();
+ switchToPage(0);
- // Hide upload area and show tool container
- uploadArea.classList.add('hidden')
- toolContainer.classList.remove('hidden')
- setTimeout(() => createIcons({ icons }), 100)
-})
+ // Hide upload area and show tool container
+ uploadArea.classList.add('hidden');
+ toolContainer.classList.remove('hidden');
+ setTimeout(() => createIcons({ icons }), 100);
+});
async function handlePdfUpload(file: File) {
- try {
- const arrayBuffer = await file.arrayBuffer()
- uploadedPdfDoc = await PDFDocument.load(arrayBuffer)
+ try {
+ const arrayBuffer = await file.arrayBuffer();
+ uploadedPdfDoc = await PDFDocument.load(arrayBuffer);
- // Check for existing fields and update counter
- existingFieldNames.clear()
- try {
- const form = uploadedPdfDoc.getForm()
- const pdfFields = form.getFields()
+ // Check for existing fields and update counter
+ existingFieldNames.clear();
+ try {
+ const form = uploadedPdfDoc.getForm();
+ const pdfFields = form.getFields();
- // console.log('๐ Found', pdfFields.length, 'existing fields in uploaded PDF')
+ // console.log('๐ Found', pdfFields.length, 'existing fields in uploaded PDF')
- pdfFields.forEach(field => {
- const name = field.getName()
- existingFieldNames.add(name) // Track all existing field names
+ pdfFields.forEach((field) => {
+ const name = field.getName();
+ existingFieldNames.add(name); // Track all existing field names
- if (field instanceof PDFRadioGroup) {
- existingRadioGroups.add(name)
- }
+ if (field instanceof PDFRadioGroup) {
+ existingRadioGroups.add(name);
+ }
- // console.log(' Field:', name, '| Type:', field.constructor.name)
+ // console.log(' Field:', name, '| Type:', field.constructor.name)
- const match = name.match(/([a-zA-Z]+)_(\d+)/)
- if (match) {
- const num = parseInt(match[2])
- if (!isNaN(num) && num > fieldCounter) {
- fieldCounter = num
- console.log(' โ Updated field counter to:', fieldCounter)
- }
- }
- })
-
- // TODO@ALAM: DEBUGGER
- // console.log('Field counter after upload:', fieldCounter)
- // console.log('Existing field names:', Array.from(existingFieldNames))
- } catch (e) {
- console.log('No form fields found or error reading fields:', e)
+ const match = name.match(/([a-zA-Z]+)_(\d+)/);
+ if (match) {
+ const num = parseInt(match[2]);
+ if (!isNaN(num) && num > fieldCounter) {
+ fieldCounter = num;
+ console.log(' โ Updated field counter to:', fieldCounter);
+ }
}
+ });
- uploadedPdfjsDoc = await getPDFDocument({ data: arrayBuffer }).promise
+ // TODO@ALAM: DEBUGGER
+ // console.log('Field counter after upload:', fieldCounter)
+ // console.log('Existing field names:', Array.from(existingFieldNames))
+ } catch (e) {
+ console.log('No form fields found or error reading fields:', e);
+ }
- const pageCount = uploadedPdfDoc.getPageCount()
- pages = []
+ uploadedPdfjsDoc = await getPDFDocument({ data: arrayBuffer }).promise;
- for (let i = 0; i < pageCount; i++) {
- const page = uploadedPdfDoc.getPage(i)
- const { width, height } = page.getSize()
+ const pageCount = uploadedPdfDoc.getPageCount();
+ pages = [];
- pages.push({
- index: i,
- width,
- height,
- pdfPageData: undefined
- })
- }
+ for (let i = 0; i < pageCount; i++) {
+ const page = uploadedPdfDoc.getPage(i);
+ const { width, height } = page.getSize();
- currentPageIndex = 0
- renderCanvas()
- updatePageNavigation()
+ pages.push({
+ index: i,
+ width,
+ height,
+ pdfPageData: undefined,
+ });
+ }
- // Hide upload area and show tool container
- uploadArea.classList.add('hidden')
- toolContainer.classList.remove('hidden')
+ currentPageIndex = 0;
+ renderCanvas();
+ updatePageNavigation();
- // Init icons
- setTimeout(() => createIcons({ icons }), 100)
- } catch (error) {
- console.error('Error loading PDF:', error)
- showModal('Error', 'Error loading PDF file. Please try again with a valid PDF.', 'error')
- }
+ // Hide upload area and show tool container
+ uploadArea.classList.add('hidden');
+ toolContainer.classList.remove('hidden');
+
+ // Init icons
+ setTimeout(() => createIcons({ icons }), 100);
+ } catch (error) {
+ console.error('Error loading PDF:', error);
+ showModal(
+ 'Error',
+ 'Error loading PDF file. Please try again with a valid PDF.',
+ 'error'
+ );
+ }
}
// Page navigation
prevPageBtn.addEventListener('click', () => {
- if (currentPageIndex > 0) {
- switchToPage(currentPageIndex - 1)
- }
-})
+ if (currentPageIndex > 0) {
+ switchToPage(currentPageIndex - 1);
+ }
+});
nextPageBtn.addEventListener('click', () => {
- if (currentPageIndex < pages.length - 1) {
- switchToPage(currentPageIndex + 1)
- }
-})
+ if (currentPageIndex < pages.length - 1) {
+ switchToPage(currentPageIndex + 1);
+ }
+});
addPageBtn.addEventListener('click', () => {
- createBlankPage()
- switchToPage(pages.length - 1)
-})
+ createBlankPage();
+ switchToPage(pages.length - 1);
+});
resetBtn.addEventListener('click', () => {
- if (fields.length > 0 || pages.length > 0) {
- if (confirm('Are you sure you want to reset? All your work will be lost.')) {
- resetToInitial()
- }
- } else {
- resetToInitial()
+ if (fields.length > 0 || pages.length > 0) {
+ if (
+ confirm('Are you sure you want to reset? All your work will be lost.')
+ ) {
+ resetToInitial();
}
-})
+ } else {
+ resetToInitial();
+ }
+});
// Custom Modal Logic
-const errorModal = document.getElementById('errorModal')
-const errorModalTitle = document.getElementById('errorModalTitle')
-const errorModalMessage = document.getElementById('errorModalMessage')
-const errorModalClose = document.getElementById('errorModalClose')
-
-let modalCloseCallback: (() => void) | null = null
-
-function showModal(title: string, message: string, type: 'error' | 'warning' | 'info' = 'error', onClose?: () => void, buttonText: string = 'Close') {
- if (!errorModal || !errorModalTitle || !errorModalMessage || !errorModalClose) return
-
- errorModalTitle.textContent = title
- errorModalMessage.textContent = message
- errorModalClose.textContent = buttonText
-
- modalCloseCallback = onClose || null
- errorModal.classList.remove('hidden')
+const errorModal = document.getElementById('errorModal');
+const errorModalTitle = document.getElementById('errorModalTitle');
+const errorModalMessage = document.getElementById('errorModalMessage');
+const errorModalClose = document.getElementById('errorModalClose');
+
+let modalCloseCallback: (() => void) | null = null;
+
+function showModal(
+ title: string,
+ message: string,
+ type: 'error' | 'warning' | 'info' = 'error',
+ onClose?: () => void,
+ buttonText: string = 'Close'
+) {
+ if (!errorModal || !errorModalTitle || !errorModalMessage || !errorModalClose)
+ return;
+
+ errorModalTitle.textContent = title;
+ errorModalMessage.textContent = message;
+ errorModalClose.textContent = buttonText;
+
+ modalCloseCallback = onClose || null;
+ errorModal.classList.remove('hidden');
}
if (errorModalClose) {
- errorModalClose.addEventListener('click', () => {
- errorModal?.classList.add('hidden')
- if (modalCloseCallback) {
- modalCloseCallback()
- modalCloseCallback = null
- }
- })
+ errorModalClose.addEventListener('click', () => {
+ errorModal?.classList.add('hidden');
+ if (modalCloseCallback) {
+ modalCloseCallback();
+ modalCloseCallback = null;
+ }
+ });
}
// Close modal on backdrop click
if (errorModal) {
- errorModal.addEventListener('click', (e) => {
- if (e.target === errorModal) {
- errorModal.classList.add('hidden')
- if (modalCloseCallback) {
- modalCloseCallback()
- modalCloseCallback = null
- }
- }
- })
+ errorModal.addEventListener('click', (e) => {
+ if (e.target === errorModal) {
+ errorModal.classList.add('hidden');
+ if (modalCloseCallback) {
+ modalCloseCallback();
+ modalCloseCallback = null;
+ }
+ }
+ });
}
-initializeGlobalShortcuts()
+initializeGlobalShortcuts();
diff --git a/src/js/logic/ocr-pdf-page.ts b/src/js/logic/ocr-pdf-page.ts
index bdadf7aa2..422ae66c3 100644
--- a/src/js/logic/ocr-pdf-page.ts
+++ b/src/js/logic/ocr-pdf-page.ts
@@ -2,556 +2,680 @@ import { tesseractLanguages } from '../config/tesseract-languages.js';
import { showAlert } from '../ui.js';
import { downloadFile, formatBytes, getPDFDocument } from '../utils/helpers.js';
import Tesseract from 'tesseract.js';
-import { PDFDocument as PDFLibDocument, StandardFonts, rgb } from 'pdf-lib';
+import {
+ PDFDocument as PDFLibDocument,
+ StandardFonts,
+ rgb,
+ PDFFont,
+} from 'pdf-lib';
import fontkit from '@pdf-lib/fontkit';
import { icons, createIcons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import { getFontForLanguage } from '../utils/font-loader.js';
-import { OcrWord, OcrState } from '@/types';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+import { OcrState, OcrLine, OcrPage } from '@/types';
+import {
+ parseHocrDocument,
+ calculateWordTransform,
+ calculateSpaceTransform,
+} from '../utils/hocr-transform.js';
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
+ 'pdfjs-dist/build/pdf.worker.min.mjs',
+ import.meta.url
+).toString();
const pageState: OcrState = {
- file: null,
- searchablePdfBytes: null,
+ file: null,
+ searchablePdfBytes: null,
};
const whitelistPresets: Record = {
- alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?-\'"',
- 'numbers-currency': '0123456789$โฌยฃยฅ.,- ',
- 'letters-only': 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ',
- 'numbers-only': '0123456789',
- invoice: '0123456789$.,/-#: ',
- forms: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,()-_/@#:',
+ alphanumeric:
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,!?-\'"',
+ 'numbers-currency': '0123456789$โฌยฃยฅ.,- ',
+ 'letters-only': 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ',
+ 'numbers-only': '0123456789',
+ invoice: '0123456789$.,/-#: ',
+ forms:
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,()-_/@#:',
};
-function parseHOCR(hocrText: string): OcrWord[] {
- const parser = new DOMParser();
- const doc = parser.parseFromString(hocrText, 'text/html');
- const words: OcrWord[] = [];
-
- const wordElements = doc.querySelectorAll('.ocrx_word');
-
- wordElements.forEach(function (wordEl) {
- const titleAttr = wordEl.getAttribute('title');
- const text = wordEl.textContent?.trim() || '';
-
- if (!titleAttr || !text) return;
-
- const bboxMatch = titleAttr.match(/bbox (\d+) (\d+) (\d+) (\d+)/);
- const confMatch = titleAttr.match(/x_wconf (\d+)/);
-
- if (bboxMatch) {
- words.push({
- text: text,
- bbox: {
- x0: parseInt(bboxMatch[1]),
- y0: parseInt(bboxMatch[2]),
- x1: parseInt(bboxMatch[3]),
- y1: parseInt(bboxMatch[4]),
- },
- confidence: confMatch ? parseInt(confMatch[1]) : 0,
+function drawOcrTextLayer(
+ page: ReturnType,
+ ocrPage: OcrPage,
+ pageHeight: number,
+ primaryFont: PDFFont,
+ latinFont: PDFFont
+): void {
+ ocrPage.lines.forEach(function (line: OcrLine) {
+ const words = line.words;
+
+ for (let i = 0; i < words.length; i++) {
+ const word = words[i];
+ const text = word.text.replace(
+ /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g,
+ ''
+ );
+
+ if (!text.trim()) continue;
+
+ const hasNonLatin = /[^\u0000-\u007F]/.test(text);
+ const font = hasNonLatin ? primaryFont : latinFont;
+
+ if (!font) {
+ console.warn('Font not available for text: "' + text + '"');
+ continue;
+ }
+
+ const transform = calculateWordTransform(
+ word,
+ line,
+ pageHeight,
+ (txt: string, size: number) => {
+ try {
+ return font.widthOfTextAtSize(txt, size);
+ } catch {
+ return 0;
+ }
+ }
+ );
+
+ if (transform.fontSize <= 0) continue;
+
+ try {
+ page.drawText(text, {
+ x: transform.x,
+ y: transform.y,
+ font,
+ size: transform.fontSize,
+ color: rgb(0, 0, 0),
+ opacity: 0,
+ });
+ } catch (error) {
+ console.warn(`Could not draw text "${text}":`, error);
+ }
+
+ if (line.injectWordBreaks && i < words.length - 1) {
+ const nextWord = words[i + 1];
+ const spaceTransform = calculateSpaceTransform(
+ word,
+ nextWord,
+ line,
+ pageHeight,
+ (size: number) => {
+ try {
+ return font.widthOfTextAtSize(' ', size);
+ } catch {
+ return 0;
+ }
+ }
+ );
+
+ if (spaceTransform && spaceTransform.horizontalScale > 0.1) {
+ try {
+ page.drawText(' ', {
+ x: spaceTransform.x,
+ y: spaceTransform.y,
+ font,
+ size: spaceTransform.fontSize,
+ color: rgb(0, 0, 0),
+ opacity: 0,
});
+ } catch {
+ console.warn(`Could not draw space between words`);
+ }
}
- });
-
- return words;
+ }
+ }
+ });
}
function binarizeCanvas(ctx: CanvasRenderingContext2D) {
- const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
- const data = imageData.data;
- for (let i = 0; i < data.length; i += 4) {
- const brightness = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
- const color = brightness > 128 ? 255 : 0;
- data[i] = data[i + 1] = data[i + 2] = color;
- }
- ctx.putImageData(imageData, 0, 0);
+ const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
+ const data = imageData.data;
+ for (let i = 0; i < data.length; i += 4) {
+ const brightness =
+ 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
+ const color = brightness > 128 ? 255 : 0;
+ data[i] = data[i + 1] = data[i + 2] = color;
+ }
+ ctx.putImageData(imageData, 0, 0);
}
function updateProgress(status: string, progress: number) {
- const progressBar = document.getElementById('progress-bar');
- const progressStatus = document.getElementById('progress-status');
- const progressLog = document.getElementById('progress-log');
+ const progressBar = document.getElementById('progress-bar');
+ const progressStatus = document.getElementById('progress-status');
+ const progressLog = document.getElementById('progress-log');
- if (!progressBar || !progressStatus || !progressLog) return;
+ if (!progressBar || !progressStatus || !progressLog) return;
- progressStatus.textContent = status;
- progressBar.style.width = `${Math.min(100, progress * 100)}%`;
+ progressStatus.textContent = status;
+ progressBar.style.width = `${Math.min(100, progress * 100)}%`;
- const logMessage = `Status: ${status}`;
- progressLog.textContent += logMessage + '\n';
- progressLog.scrollTop = progressLog.scrollHeight;
+ const logMessage = `Status: ${status}`;
+ progressLog.textContent += logMessage + '\n';
+ progressLog.scrollTop = progressLog.scrollHeight;
}
function resetState() {
- pageState.file = null;
- pageState.searchablePdfBytes = null;
+ pageState.file = null;
+ pageState.searchablePdfBytes = null;
- const fileDisplayArea = document.getElementById('file-display-area');
- if (fileDisplayArea) fileDisplayArea.innerHTML = '';
+ const fileDisplayArea = document.getElementById('file-display-area');
+ if (fileDisplayArea) fileDisplayArea.innerHTML = '';
- const toolOptions = document.getElementById('tool-options');
- if (toolOptions) toolOptions.classList.add('hidden');
+ const toolOptions = document.getElementById('tool-options');
+ if (toolOptions) toolOptions.classList.add('hidden');
- const ocrProgress = document.getElementById('ocr-progress');
- if (ocrProgress) ocrProgress.classList.add('hidden');
+ const ocrProgress = document.getElementById('ocr-progress');
+ if (ocrProgress) ocrProgress.classList.add('hidden');
- const ocrResults = document.getElementById('ocr-results');
- if (ocrResults) ocrResults.classList.add('hidden');
+ const ocrResults = document.getElementById('ocr-results');
+ if (ocrResults) ocrResults.classList.add('hidden');
- const progressLog = document.getElementById('progress-log');
- if (progressLog) progressLog.textContent = '';
+ const progressLog = document.getElementById('progress-log');
+ if (progressLog) progressLog.textContent = '';
- const progressBar = document.getElementById('progress-bar');
- if (progressBar) progressBar.style.width = '0%';
+ const progressBar = document.getElementById('progress-bar');
+ if (progressBar) progressBar.style.width = '0%';
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- if (fileInput) fileInput.value = '';
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ if (fileInput) fileInput.value = '';
- // Reset selected languages
- const langCheckboxes = document.querySelectorAll('.lang-checkbox') as NodeListOf;
- langCheckboxes.forEach(function (cb) { cb.checked = false; });
+ // Reset selected languages
+ const langCheckboxes = document.querySelectorAll(
+ '.lang-checkbox'
+ ) as NodeListOf;
+ langCheckboxes.forEach(function (cb) {
+ cb.checked = false;
+ });
- const selectedLangsDisplay = document.getElementById('selected-langs-display');
- if (selectedLangsDisplay) selectedLangsDisplay.textContent = 'None';
+ const selectedLangsDisplay = document.getElementById(
+ 'selected-langs-display'
+ );
+ if (selectedLangsDisplay) selectedLangsDisplay.textContent = 'None';
- const processBtn = document.getElementById('process-btn') as HTMLButtonElement;
- if (processBtn) processBtn.disabled = true;
+ const processBtn = document.getElementById(
+ 'process-btn'
+ ) as HTMLButtonElement;
+ if (processBtn) processBtn.disabled = true;
}
async function runOCR() {
- const selectedLangs = Array.from(
- document.querySelectorAll('.lang-checkbox:checked')
- ).map(function (cb) { return (cb as HTMLInputElement).value; });
-
- const scale = parseFloat(
- (document.getElementById('ocr-resolution') as HTMLSelectElement).value
+ const selectedLangs = Array.from(
+ document.querySelectorAll('.lang-checkbox:checked')
+ ).map(function (cb) {
+ return (cb as HTMLInputElement).value;
+ });
+
+ const scale = parseFloat(
+ (document.getElementById('ocr-resolution') as HTMLSelectElement).value
+ );
+ const binarize = (document.getElementById('ocr-binarize') as HTMLInputElement)
+ .checked;
+ const whitelist = (
+ document.getElementById('ocr-whitelist') as HTMLInputElement
+ ).value;
+
+ if (selectedLangs.length === 0) {
+ showAlert(
+ 'No Languages Selected',
+ 'Please select at least one language for OCR.'
);
- const binarize = (document.getElementById('ocr-binarize') as HTMLInputElement).checked;
- const whitelist = (document.getElementById('ocr-whitelist') as HTMLInputElement).value;
-
- if (selectedLangs.length === 0) {
- showAlert('No Languages Selected', 'Please select at least one language for OCR.');
- return;
- }
-
- if (!pageState.file) {
- showAlert('No File', 'Please upload a PDF file first.');
- return;
- }
-
- const langString = selectedLangs.join('+');
-
- const toolOptions = document.getElementById('tool-options');
- const ocrProgress = document.getElementById('ocr-progress');
-
- if (toolOptions) toolOptions.classList.add('hidden');
- if (ocrProgress) ocrProgress.classList.remove('hidden');
-
- try {
- const worker = await Tesseract.createWorker(langString, 1, {
- logger: function (m: { status: string; progress: number }) {
- updateProgress(m.status, m.progress || 0);
- },
- });
-
- await worker.setParameters({
- tessjs_create_hocr: '1',
- tessedit_pageseg_mode: Tesseract.PSM.AUTO,
- });
-
- if (whitelist) {
- await worker.setParameters({
- tessedit_char_whitelist: whitelist,
- });
- }
+ return;
+ }
- const arrayBuffer = await pageState.file.arrayBuffer();
- const pdf = await getPDFDocument({ data: arrayBuffer }).promise;
- const newPdfDoc = await PDFLibDocument.create();
+ if (!pageState.file) {
+ showAlert('No File', 'Please upload a PDF file first.');
+ return;
+ }
- newPdfDoc.registerFontkit(fontkit);
+ const langString = selectedLangs.join('+');
- updateProgress('Loading fonts...', 0);
+ const toolOptions = document.getElementById('tool-options');
+ const ocrProgress = document.getElementById('ocr-progress');
- const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor'];
- const indicLangs = ['hin', 'ben', 'guj', 'kan', 'mal', 'ori', 'pan', 'tam', 'tel', 'sin'];
- const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr'];
+ if (toolOptions) toolOptions.classList.add('hidden');
+ if (ocrProgress) ocrProgress.classList.remove('hidden');
- const primaryLang = selectedLangs.find(function (l) { return priorityLangs.includes(l); }) || selectedLangs[0] || 'eng';
-
- const hasCJK = selectedLangs.some(function (l) { return cjkLangs.includes(l); });
- const hasIndic = selectedLangs.some(function (l) { return indicLangs.includes(l); });
- const hasLatin = selectedLangs.some(function (l) { return !priorityLangs.includes(l); }) || selectedLangs.includes('eng');
- const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK;
-
- let primaryFont;
- let latinFont;
-
- try {
- if (isIndicPlusLatin) {
- const [scriptFontBytes, latinFontBytes] = await Promise.all([
- getFontForLanguage(primaryLang),
- getFontForLanguage('eng')
- ]);
- primaryFont = await newPdfDoc.embedFont(scriptFontBytes, { subset: false });
- latinFont = await newPdfDoc.embedFont(latinFontBytes, { subset: false });
- } else {
- const fontBytes = await getFontForLanguage(primaryLang);
- primaryFont = await newPdfDoc.embedFont(fontBytes, { subset: false });
- latinFont = primaryFont;
- }
- } catch (e) {
- console.error('Font loading failed, falling back to Helvetica', e);
- primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica);
- latinFont = primaryFont;
- showAlert('Font Warning', 'Could not load the specific font for this language. Some characters may not appear correctly.');
- }
-
- let fullText = '';
-
- for (let i = 1; i <= pdf.numPages; i++) {
- updateProgress(`Processing page ${i} of ${pdf.numPages}`, (i - 1) / pdf.numPages);
-
- const page = await pdf.getPage(i);
- const viewport = page.getViewport({ scale });
-
- const canvas = document.createElement('canvas');
- canvas.width = viewport.width;
- canvas.height = viewport.height;
- const context = canvas.getContext('2d')!;
-
- await page.render({ canvasContext: context, viewport, canvas }).promise;
-
- if (binarize) {
- binarizeCanvas(context);
- }
-
- const result = await worker.recognize(canvas, {}, { text: true, hocr: true });
- const data = result.data;
-
- const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
-
- const pngImageBytes = await new Promise(function (resolve) {
- canvas.toBlob(function (blob) {
- const reader = new FileReader();
- reader.onload = function () {
- resolve(new Uint8Array(reader.result as ArrayBuffer));
- };
- reader.readAsArrayBuffer(blob!);
- }, 'image/png');
- });
+ try {
+ const worker = await Tesseract.createWorker(langString, 1, {
+ logger: function (m: { status: string; progress: number }) {
+ updateProgress(m.status, m.progress || 0);
+ },
+ });
- const pngImage = await newPdfDoc.embedPng(pngImageBytes);
- newPage.drawImage(pngImage, {
- x: 0,
- y: 0,
- width: viewport.width,
- height: viewport.height,
- });
+ await worker.setParameters({
+ tessjs_create_hocr: '1',
+ tessedit_pageseg_mode: Tesseract.PSM.AUTO,
+ });
- if (data.hocr) {
- const words = parseHOCR(data.hocr);
-
- words.forEach(function (word: OcrWord) {
- const { x0, y0, x1, y1 } = word.bbox;
- const text = word.text.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '');
-
- if (!text.trim()) return;
-
- const hasNonLatin = /[^\u0000-\u007F]/.test(text);
- const font = hasNonLatin ? primaryFont : latinFont;
-
- if (!font) {
- console.warn(`Font not available for text: "${text}"`);
- return;
- }
-
- const bboxWidth = x1 - x0;
- const bboxHeight = y1 - y0;
-
- if (bboxWidth <= 0 || bboxHeight <= 0) {
- return;
- }
-
- let fontSize = bboxHeight * 0.9;
- try {
- let textWidth = font.widthOfTextAtSize(text, fontSize);
- while (textWidth > bboxWidth && fontSize > 1) {
- fontSize -= 0.5;
- textWidth = font.widthOfTextAtSize(text, fontSize);
- }
- } catch (error) {
- console.warn(`Could not calculate text width for "${text}":`, error);
- return;
- }
-
- try {
- newPage.drawText(text, {
- x: x0,
- y: viewport.height - y1 + (bboxHeight - fontSize) / 2,
- font,
- size: fontSize,
- color: rgb(0, 0, 0),
- opacity: 0,
- });
- } catch (error) {
- console.warn(`Could not draw text "${text}":`, error);
- }
- });
- }
+ if (whitelist) {
+ await worker.setParameters({
+ tessedit_char_whitelist: whitelist,
+ });
+ }
- fullText += data.text + '\n\n';
- }
+ const arrayBuffer = await pageState.file.arrayBuffer();
+ const pdf = await getPDFDocument({ data: arrayBuffer }).promise;
+ const newPdfDoc = await PDFLibDocument.create();
+
+ newPdfDoc.registerFontkit(fontkit);
+
+ updateProgress('Loading fonts...', 0);
+
+ const cjkLangs = ['jpn', 'chi_sim', 'chi_tra', 'kor'];
+ const indicLangs = [
+ 'hin',
+ 'ben',
+ 'guj',
+ 'kan',
+ 'mal',
+ 'ori',
+ 'pan',
+ 'tam',
+ 'tel',
+ 'sin',
+ ];
+ const priorityLangs = [...cjkLangs, ...indicLangs, 'ara', 'rus', 'ukr'];
+
+ const primaryLang =
+ selectedLangs.find(function (l) {
+ return priorityLangs.includes(l);
+ }) ||
+ selectedLangs[0] ||
+ 'eng';
+
+ const hasCJK = selectedLangs.some(function (l) {
+ return cjkLangs.includes(l);
+ });
+ const hasIndic = selectedLangs.some(function (l) {
+ return indicLangs.includes(l);
+ });
+ const hasLatin =
+ selectedLangs.some(function (l) {
+ return !priorityLangs.includes(l);
+ }) || selectedLangs.includes('eng');
+ const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK;
- await worker.terminate();
+ let primaryFont;
+ let latinFont;
- pageState.searchablePdfBytes = await newPdfDoc.save();
+ try {
+ if (isIndicPlusLatin) {
+ const [scriptFontBytes, latinFontBytes] = await Promise.all([
+ getFontForLanguage(primaryLang),
+ getFontForLanguage('eng'),
+ ]);
+ primaryFont = await newPdfDoc.embedFont(scriptFontBytes, {
+ subset: false,
+ });
+ latinFont = await newPdfDoc.embedFont(latinFontBytes, {
+ subset: false,
+ });
+ } else {
+ const fontBytes = await getFontForLanguage(primaryLang);
+ primaryFont = await newPdfDoc.embedFont(fontBytes, { subset: false });
+ latinFont = primaryFont;
+ }
+ } catch (e) {
+ console.error('Font loading failed, falling back to Helvetica', e);
+ primaryFont = await newPdfDoc.embedFont(StandardFonts.Helvetica);
+ latinFont = primaryFont;
+ showAlert(
+ 'Font Warning',
+ 'Could not load the specific font for this language. Some characters may not appear correctly.'
+ );
+ }
- const ocrResults = document.getElementById('ocr-results');
- if (ocrProgress) ocrProgress.classList.add('hidden');
- if (ocrResults) ocrResults.classList.remove('hidden');
+ let fullText = '';
+
+ for (let i = 1; i <= pdf.numPages; i++) {
+ updateProgress(
+ `Processing page ${i} of ${pdf.numPages}`,
+ (i - 1) / pdf.numPages
+ );
+
+ const page = await pdf.getPage(i);
+ const viewport = page.getViewport({ scale });
+
+ const canvas = document.createElement('canvas');
+ canvas.width = viewport.width;
+ canvas.height = viewport.height;
+ const context = canvas.getContext('2d')!;
+
+ await page.render({ canvasContext: context, viewport, canvas }).promise;
+
+ if (binarize) {
+ binarizeCanvas(context);
+ }
+
+ const result = await worker.recognize(
+ canvas,
+ {},
+ { text: true, hocr: true }
+ );
+ const data = result.data;
+
+ const newPage = newPdfDoc.addPage([viewport.width, viewport.height]);
+
+ const pngImageBytes = await new Promise(function (resolve) {
+ canvas.toBlob(function (blob) {
+ const reader = new FileReader();
+ reader.onload = function () {
+ resolve(new Uint8Array(reader.result as ArrayBuffer));
+ };
+ reader.readAsArrayBuffer(blob!);
+ }, 'image/png');
+ });
+
+ const pngImage = await newPdfDoc.embedPng(pngImageBytes);
+ newPage.drawImage(pngImage, {
+ x: 0,
+ y: 0,
+ width: viewport.width,
+ height: viewport.height,
+ });
+
+ if (data.hocr) {
+ const ocrPage = parseHocrDocument(data.hocr);
+ drawOcrTextLayer(
+ newPage,
+ ocrPage,
+ viewport.height,
+ primaryFont,
+ latinFont
+ );
+ }
+
+ fullText += data.text + '\n\n';
+ }
- createIcons({ icons });
+ await worker.terminate();
- const textOutput = document.getElementById('ocr-text-output') as HTMLTextAreaElement;
- if (textOutput) textOutput.value = fullText.trim();
+ pageState.searchablePdfBytes = await newPdfDoc.save();
- } catch (e) {
- console.error(e);
- showAlert('OCR Error', 'An error occurred during the OCR process. The worker may have failed to load. Please try again.');
- if (toolOptions) toolOptions.classList.remove('hidden');
- if (ocrProgress) ocrProgress.classList.add('hidden');
- }
+ const ocrResults = document.getElementById('ocr-results');
+ if (ocrProgress) ocrProgress.classList.add('hidden');
+ if (ocrResults) ocrResults.classList.remove('hidden');
+
+ createIcons({ icons });
+
+ const textOutput = document.getElementById(
+ 'ocr-text-output'
+ ) as HTMLTextAreaElement;
+ if (textOutput) textOutput.value = fullText.trim();
+ } catch (e) {
+ console.error(e);
+ showAlert(
+ 'OCR Error',
+ 'An error occurred during the OCR process. The worker may have failed to load. Please try again.'
+ );
+ if (toolOptions) toolOptions.classList.remove('hidden');
+ if (ocrProgress) ocrProgress.classList.add('hidden');
+ }
}
async function updateUI() {
- const fileDisplayArea = document.getElementById('file-display-area');
- const toolOptions = document.getElementById('tool-options');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const toolOptions = document.getElementById('tool-options');
- if (!fileDisplayArea) return;
+ if (!fileDisplayArea) return;
- fileDisplayArea.innerHTML = '';
+ fileDisplayArea.innerHTML = '';
- if (pageState.file) {
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ if (pageState.file) {
+ const fileDiv = document.createElement('div');
+ fileDiv.className =
+ 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
- const infoContainer = document.createElement('div');
- infoContainer.className = 'flex flex-col overflow-hidden';
+ const infoContainer = document.createElement('div');
+ infoContainer.className = 'flex flex-col overflow-hidden';
- const nameSpan = document.createElement('div');
- nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
- nameSpan.textContent = pageState.file.name;
+ const nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = pageState.file.name;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = formatBytes(pageState.file.size);
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = formatBytes(pageState.file.size);
- infoContainer.append(nameSpan, metaSpan);
+ infoContainer.append(nameSpan, metaSpan);
- const removeBtn = document.createElement('button');
- removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
- removeBtn.innerHTML = ' ';
- removeBtn.onclick = function () {
- resetState();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = ' ';
+ removeBtn.onclick = function () {
+ resetState();
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
- createIcons({ icons });
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ createIcons({ icons });
- if (toolOptions) toolOptions.classList.remove('hidden');
- } else {
- if (toolOptions) toolOptions.classList.add('hidden');
- }
+ if (toolOptions) toolOptions.classList.remove('hidden');
+ } else {
+ if (toolOptions) toolOptions.classList.add('hidden');
+ }
}
function handleFileSelect(files: FileList | null) {
- if (files && files.length > 0) {
- const file = files[0];
- if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
- pageState.file = file;
- updateUI();
- }
+ if (files && files.length > 0) {
+ const file = files[0];
+ if (
+ file.type === 'application/pdf' ||
+ file.name.toLowerCase().endsWith('.pdf')
+ ) {
+ pageState.file = file;
+ updateUI();
}
+ }
}
function populateLanguageList() {
- const langList = document.getElementById('lang-list');
- if (!langList) return;
-
- langList.innerHTML = '';
-
- Object.entries(tesseractLanguages).forEach(function ([code, name]) {
- const label = document.createElement('label');
- label.className = 'flex items-center gap-2 p-2 rounded-md hover:bg-gray-700 cursor-pointer';
-
- const checkbox = document.createElement('input');
- checkbox.type = 'checkbox';
- checkbox.value = code;
- checkbox.className = 'lang-checkbox w-4 h-4 rounded text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500';
-
- label.append(checkbox);
- label.append(document.createTextNode(' ' + name));
- langList.appendChild(label);
- });
+ const langList = document.getElementById('lang-list');
+ if (!langList) return;
+
+ langList.innerHTML = '';
+
+ Object.entries(tesseractLanguages).forEach(function ([code, name]) {
+ const label = document.createElement('label');
+ label.className =
+ 'flex items-center gap-2 p-2 rounded-md hover:bg-gray-700 cursor-pointer';
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.value = code;
+ checkbox.className =
+ 'lang-checkbox w-4 h-4 rounded text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500';
+
+ label.append(checkbox);
+ label.append(document.createTextNode(' ' + name));
+ langList.appendChild(label);
+ });
}
document.addEventListener('DOMContentLoaded', function () {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn') as HTMLButtonElement;
- const backBtn = document.getElementById('back-to-tools');
- const langSearch = document.getElementById('lang-search') as HTMLInputElement;
- const langList = document.getElementById('lang-list');
- const selectedLangsDisplay = document.getElementById('selected-langs-display');
- const presetSelect = document.getElementById('whitelist-preset') as HTMLSelectElement;
- const whitelistInput = document.getElementById('ocr-whitelist') as HTMLInputElement;
- const copyBtn = document.getElementById('copy-text-btn');
- const downloadTxtBtn = document.getElementById('download-txt-btn');
- const downloadPdfBtn = document.getElementById('download-searchable-pdf');
-
- populateLanguageList();
-
- if (backBtn) {
- backBtn.addEventListener('click', function () {
- window.location.href = import.meta.env.BASE_URL;
- });
- }
-
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', function (e) {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
-
- dropZone.addEventListener('dragover', function (e) {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
-
- dropZone.addEventListener('dragleave', function (e) {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', function (e) {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- const files = e.dataTransfer?.files;
- if (files && files.length > 0) {
- const pdfFiles = Array.from(files).filter(function (f) {
- return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
- });
- if (pdfFiles.length > 0) {
- const dataTransfer = new DataTransfer();
- dataTransfer.items.add(pdfFiles[0]);
- handleFileSelect(dataTransfer.files);
- }
- }
- });
-
- fileInput.addEventListener('click', function () {
- fileInput.value = '';
- });
- }
-
- // Language search
- if (langSearch && langList) {
- langSearch.addEventListener('input', function () {
- const searchTerm = langSearch.value.toLowerCase();
- langList.querySelectorAll('label').forEach(function (label) {
- (label as HTMLElement).style.display = label.textContent?.toLowerCase().includes(searchTerm) ? '' : 'none';
- });
- });
-
- langList.addEventListener('change', function () {
- const selected = Array.from(
- langList.querySelectorAll('.lang-checkbox:checked')
- ).map(function (cb) {
- return tesseractLanguages[(cb as HTMLInputElement).value as keyof typeof tesseractLanguages];
- });
-
- if (selectedLangsDisplay) {
- selectedLangsDisplay.textContent = selected.length > 0 ? selected.join(', ') : 'None';
- }
-
- if (processBtn) {
- processBtn.disabled = selected.length === 0;
- }
- });
- }
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById(
+ 'process-btn'
+ ) as HTMLButtonElement;
+ const backBtn = document.getElementById('back-to-tools');
+ const langSearch = document.getElementById('lang-search') as HTMLInputElement;
+ const langList = document.getElementById('lang-list');
+ const selectedLangsDisplay = document.getElementById(
+ 'selected-langs-display'
+ );
+ const presetSelect = document.getElementById(
+ 'whitelist-preset'
+ ) as HTMLSelectElement;
+ const whitelistInput = document.getElementById(
+ 'ocr-whitelist'
+ ) as HTMLInputElement;
+ const copyBtn = document.getElementById('copy-text-btn');
+ const downloadTxtBtn = document.getElementById('download-txt-btn');
+ const downloadPdfBtn = document.getElementById('download-searchable-pdf');
+
+ populateLanguageList();
+
+ if (backBtn) {
+ backBtn.addEventListener('click', function () {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
- // Whitelist preset
- if (presetSelect && whitelistInput) {
- presetSelect.addEventListener('change', function () {
- const preset = presetSelect.value;
- if (preset && preset !== 'custom') {
- whitelistInput.value = whitelistPresets[preset] || '';
- whitelistInput.disabled = true;
- } else {
- whitelistInput.disabled = false;
- if (preset === '') {
- whitelistInput.value = '';
- }
- }
- });
- }
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', function (e) {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
- // Details toggle
- document.querySelectorAll('details').forEach(function (details) {
- details.addEventListener('toggle', function () {
- const icon = details.querySelector('.details-icon') as HTMLElement;
- if (icon) {
- icon.style.transform = (details as HTMLDetailsElement).open ? 'rotate(180deg)' : 'rotate(0deg)';
- }
- });
+ dropZone.addEventListener('dragover', function (e) {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
});
- // Process button
- if (processBtn) {
- processBtn.addEventListener('click', runOCR);
- }
+ dropZone.addEventListener('dragleave', function (e) {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
- // Copy button
- if (copyBtn) {
- copyBtn.addEventListener('click', function () {
- const textOutput = document.getElementById('ocr-text-output') as HTMLTextAreaElement;
- if (textOutput) {
- navigator.clipboard.writeText(textOutput.value).then(function () {
- copyBtn.innerHTML = ' ';
- createIcons({ icons });
-
- setTimeout(function () {
- copyBtn.innerHTML = ' ';
- createIcons({ icons });
- }, 2000);
- });
- }
+ dropZone.addEventListener('drop', function (e) {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ const files = e.dataTransfer?.files;
+ if (files && files.length > 0) {
+ const pdfFiles = Array.from(files).filter(function (f) {
+ return (
+ f.type === 'application/pdf' ||
+ f.name.toLowerCase().endsWith('.pdf')
+ );
});
- }
+ if (pdfFiles.length > 0) {
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(pdfFiles[0]);
+ handleFileSelect(dataTransfer.files);
+ }
+ }
+ });
- // Download txt
- if (downloadTxtBtn) {
- downloadTxtBtn.addEventListener('click', function () {
- const textOutput = document.getElementById('ocr-text-output') as HTMLTextAreaElement;
- if (textOutput) {
- const blob = new Blob([textOutput.value], { type: 'text/plain' });
- downloadFile(blob, 'ocr-text.txt');
- }
- });
- }
+ fileInput.addEventListener('click', function () {
+ fileInput.value = '';
+ });
+ }
+
+ // Language search
+ if (langSearch && langList) {
+ langSearch.addEventListener('input', function () {
+ const searchTerm = langSearch.value.toLowerCase();
+ langList.querySelectorAll('label').forEach(function (label) {
+ (label as HTMLElement).style.display = label.textContent
+ ?.toLowerCase()
+ .includes(searchTerm)
+ ? ''
+ : 'none';
+ });
+ });
- // Download PDF
- if (downloadPdfBtn) {
- downloadPdfBtn.addEventListener('click', function () {
- if (pageState.searchablePdfBytes) {
- downloadFile(
- new Blob([new Uint8Array(pageState.searchablePdfBytes)], { type: 'application/pdf' }),
- 'searchable.pdf'
- );
- }
+ langList.addEventListener('change', function () {
+ const selected = Array.from(
+ langList.querySelectorAll('.lang-checkbox:checked')
+ ).map(function (cb) {
+ return tesseractLanguages[
+ (cb as HTMLInputElement).value as keyof typeof tesseractLanguages
+ ];
+ });
+
+ if (selectedLangsDisplay) {
+ selectedLangsDisplay.textContent =
+ selected.length > 0 ? selected.join(', ') : 'None';
+ }
+
+ if (processBtn) {
+ processBtn.disabled = selected.length === 0;
+ }
+ });
+ }
+
+ // Whitelist preset
+ if (presetSelect && whitelistInput) {
+ presetSelect.addEventListener('change', function () {
+ const preset = presetSelect.value;
+ if (preset && preset !== 'custom') {
+ whitelistInput.value = whitelistPresets[preset] || '';
+ whitelistInput.disabled = true;
+ } else {
+ whitelistInput.disabled = false;
+ if (preset === '') {
+ whitelistInput.value = '';
+ }
+ }
+ });
+ }
+
+ // Details toggle
+ document.querySelectorAll('details').forEach(function (details) {
+ details.addEventListener('toggle', function () {
+ const icon = details.querySelector('.details-icon') as HTMLElement;
+ if (icon) {
+ icon.style.transform = (details as HTMLDetailsElement).open
+ ? 'rotate(180deg)'
+ : 'rotate(0deg)';
+ }
+ });
+ });
+
+ // Process button
+ if (processBtn) {
+ processBtn.addEventListener('click', runOCR);
+ }
+
+ // Copy button
+ if (copyBtn) {
+ copyBtn.addEventListener('click', function () {
+ const textOutput = document.getElementById(
+ 'ocr-text-output'
+ ) as HTMLTextAreaElement;
+ if (textOutput) {
+ navigator.clipboard.writeText(textOutput.value).then(function () {
+ copyBtn.innerHTML =
+ ' ';
+ createIcons({ icons });
+
+ setTimeout(function () {
+ copyBtn.innerHTML =
+ ' ';
+ createIcons({ icons });
+ }, 2000);
});
- }
+ }
+ });
+ }
+
+ // Download txt
+ if (downloadTxtBtn) {
+ downloadTxtBtn.addEventListener('click', function () {
+ const textOutput = document.getElementById(
+ 'ocr-text-output'
+ ) as HTMLTextAreaElement;
+ if (textOutput) {
+ const blob = new Blob([textOutput.value], { type: 'text/plain' });
+ downloadFile(blob, 'ocr-text.txt');
+ }
+ });
+ }
+
+ // Download PDF
+ if (downloadPdfBtn) {
+ downloadPdfBtn.addEventListener('click', function () {
+ if (pageState.searchablePdfBytes) {
+ downloadFile(
+ new Blob([new Uint8Array(pageState.searchablePdfBytes)], {
+ type: 'application/pdf',
+ }),
+ 'searchable.pdf'
+ );
+ }
+ });
+ }
});
diff --git a/src/js/main.ts b/src/js/main.ts
index c56ef1446..d05df06ed 100644
--- a/src/js/main.ts
+++ b/src/js/main.ts
@@ -28,28 +28,6 @@ const init = async () => {
).toString();
if (__SIMPLE_MODE__) {
const hideBrandingSections = () => {
- const nav = document.querySelector('nav');
- if (nav) {
- nav.style.display = 'none';
-
- const simpleNav = document.createElement('nav');
- simpleNav.className =
- 'bg-gray-800 border-b border-gray-700 sticky top-0 z-30';
- simpleNav.innerHTML = `
-
- `;
- document.body.insertBefore(simpleNav, document.body.firstChild);
- }
-
const heroSection = document.getElementById('hero-section');
if (heroSection) {
heroSection.style.display = 'none';
@@ -99,48 +77,6 @@ const init = async () => {
usedBySection.style.display = 'none';
}
- const footer = document.querySelector('footer');
- if (footer && !document.querySelector('[data-simple-footer]')) {
- footer.style.display = 'none';
-
- const simpleFooter = document.createElement('footer');
- simpleFooter.className = 'mt-16 border-t-2 border-gray-700 py-8';
- simpleFooter.setAttribute('data-simple-footer', 'true');
- simpleFooter.innerHTML = `
-
-
-
-
-
-
BentoPDF
-
-
- © 2026 BentoPDF. All rights reserved.
-
-
- Version ${APP_VERSION}
-
-
-
-
-
- `;
- document.body.appendChild(simpleFooter);
-
- const langContainer = simpleFooter.querySelector(
- '#simple-mode-lang-switcher'
- );
- if (langContainer) {
- const switcher = createLanguageSwitcher();
- const dropdown = switcher.querySelector('div[role="menu"]');
- if (dropdown) {
- dropdown.classList.remove('mt-2');
- dropdown.classList.add('bottom-full', 'mb-2');
- }
- langContainer.appendChild(switcher);
- }
- }
-
const sectionDividers = document.querySelectorAll('.section-divider');
sectionDividers.forEach((divider) => {
(divider as HTMLElement).style.display = 'none';
@@ -270,6 +206,11 @@ const init = async () => {
'Flatten PDF': 'tools:flattenPdf',
'Remove Metadata': 'tools:removeMetadata',
'Change Permissions': 'tools:changePermissions',
+ 'Email to PDF': 'tools:emailToPdf',
+ 'Font to Outline': 'tools:fontToOutline',
+ 'Deskew PDF': 'tools:deskewPdf',
+ 'Digital Signature': 'tools:digitalSignPdf',
+ 'Validate Signature': 'tools:validateSignaturePdf',
};
// Homepage-only tool grid rendering (not used on individual tool pages)
diff --git a/src/js/types/bookmark-pdf-type.ts b/src/js/types/bookmark-pdf-type.ts
new file mode 100644
index 000000000..805beb5e2
--- /dev/null
+++ b/src/js/types/bookmark-pdf-type.ts
@@ -0,0 +1,179 @@
+import { PDFDocument as PDFLibDocument, PDFRef } from 'pdf-lib';
+import { PDFDocumentProxy, PageViewport } from 'pdfjs-dist';
+
+// Core bookmark types
+export type BookmarkColor =
+ | 'red'
+ | 'blue'
+ | 'green'
+ | 'yellow'
+ | 'purple'
+ | null;
+export type BookmarkStyle = 'bold' | 'italic' | 'bold-italic' | null;
+
+export interface BookmarkNode {
+ id: number;
+ title: string;
+ page: number;
+ children: BookmarkNode[];
+ color: BookmarkColor | string;
+ style: BookmarkStyle;
+ destX: number | null;
+ destY: number | null;
+ zoom: string | null;
+}
+
+export type BookmarkTree = BookmarkNode[];
+
+// Modal system types
+export type ModalFieldType = 'text' | 'select' | 'destination' | 'preview';
+
+export interface SelectOption {
+ value: string;
+ label: string;
+}
+
+export interface BaseModalField {
+ name: string;
+ label: string;
+}
+
+export interface TextModalField extends BaseModalField {
+ type: 'text';
+ placeholder?: string;
+}
+
+export interface SelectModalField extends BaseModalField {
+ type: 'select';
+ options: SelectOption[];
+}
+
+export interface DestinationModalField extends BaseModalField {
+ type: 'destination';
+ page?: number;
+ maxPages?: number;
+}
+
+export interface PreviewModalField {
+ type: 'preview';
+ label: string;
+}
+
+export type ModalField =
+ | TextModalField
+ | SelectModalField
+ | DestinationModalField
+ | PreviewModalField;
+
+export interface ModalResult {
+ title?: string;
+ color?: string;
+ style?: string;
+ destPage?: number | null;
+ destX?: number | null;
+ destY?: number | null;
+ zoom?: string | null;
+ [key: string]: string | number | null | undefined;
+}
+
+export interface ModalDefaultValues {
+ title?: string;
+ color?: string;
+ style?: string;
+ destPage?: number;
+ destX?: number | null;
+ destY?: number | null;
+ zoom?: string | null;
+ [key: string]: string | number | null | undefined;
+}
+
+// Destination picking types
+export type DestinationCallback = (
+ page: number,
+ pdfX: number,
+ pdfY: number
+) => void;
+
+export interface DestinationPickingState {
+ isPickingDestination: boolean;
+ currentPickingCallback: DestinationCallback | null;
+ destinationMarker: HTMLDivElement | null;
+ savedModalOverlay: HTMLDivElement | null;
+ savedModal: HTMLDivElement | null;
+ currentViewport: PageViewport | null;
+}
+
+// State types
+export interface BookmarkEditorState {
+ pdfLibDoc: PDFLibDocument | null;
+ pdfJsDoc: PDFDocumentProxy | null;
+ currentPage: number;
+ currentZoom: number;
+ originalFileName: string;
+ bookmarkTree: BookmarkTree;
+ history: BookmarkTree[];
+ historyIndex: number;
+ searchQuery: string;
+ csvBookmarks: BookmarkTree | null;
+ jsonBookmarks: BookmarkTree | null;
+ batchMode: boolean;
+ selectedBookmarks: Set;
+ collapsedNodes: Set;
+}
+
+// PDF outline types (from pdfjs-dist)
+export interface PDFOutlineItem {
+ title: string;
+ dest: string | unknown[] | null;
+ items?: PDFOutlineItem[];
+ color?: Uint8ClampedArray | [number, number, number];
+ bold?: boolean;
+ italic?: boolean;
+}
+
+export interface FlattenedBookmark extends BookmarkNode {
+ level: number;
+}
+
+// Outline item for PDF creation
+export interface OutlineItem {
+ ref: PDFRef;
+ dict: {
+ set: (key: unknown, value: unknown) => void;
+ };
+}
+
+// Color mapping types
+export type ColorClassMap = Record;
+
+export const COLOR_CLASSES: ColorClassMap = {
+ red: 'bg-red-100 border-red-300',
+ blue: 'bg-blue-100 border-blue-300',
+ green: 'bg-green-100 border-green-300',
+ yellow: 'bg-yellow-100 border-yellow-300',
+ purple: 'bg-purple-100 border-purple-300',
+};
+
+export const TEXT_COLOR_CLASSES: ColorClassMap = {
+ red: 'text-red-600',
+ blue: 'text-blue-600',
+ green: 'text-green-600',
+ yellow: 'text-yellow-600',
+ purple: 'text-purple-600',
+};
+
+export const HEX_COLOR_MAP: Record = {
+ red: '#dc2626',
+ blue: '#2563eb',
+ green: '#16a34a',
+ yellow: '#ca8a04',
+ purple: '#9333ea',
+};
+
+export const PDF_COLOR_MAP: Record = {
+ red: [1.0, 0.0, 0.0],
+ blue: [0.0, 0.0, 1.0],
+ green: [0.0, 1.0, 0.0],
+ yellow: [1.0, 1.0, 0.0],
+ purple: [0.5, 0.0, 0.5],
+};
diff --git a/src/js/types/email-to-pdf-type.ts b/src/js/types/email-to-pdf-type.ts
new file mode 100644
index 000000000..89d81668e
--- /dev/null
+++ b/src/js/types/email-to-pdf-type.ts
@@ -0,0 +1,26 @@
+export interface EmailAttachment {
+ filename: string;
+ size: number;
+ contentType: string;
+ content?: Uint8Array;
+ contentId?: string;
+}
+
+export interface ParsedEmail {
+ subject: string;
+ from: string;
+ to: string[];
+ cc: string[];
+ bcc: string[];
+ date: Date | null;
+ rawDateString: string;
+ htmlBody: string;
+ textBody: string;
+ attachments: EmailAttachment[];
+}
+
+export interface EmailRenderOptions {
+ includeCcBcc?: boolean;
+ includeAttachments?: boolean;
+ pageSize?: 'a4' | 'letter' | 'legal';
+}
diff --git a/src/js/types/index.ts b/src/js/types/index.ts
index 9f73240ea..089a0ad90 100644
--- a/src/js/types/index.ts
+++ b/src/js/types/index.ts
@@ -1,48 +1,49 @@
-export * from './ocr-type.js';
-export * from './form-creator-type.js';
-export * from './digital-sign-type.js';
-export * from './attachment-type.js';
-export * from './edit-attachments-type.js';
-export * from './edit-metadata-type.js';
-export * from './divide-pages-type.js';
-export * from './alternate-merge-page-type.js';
-export * from './add-blank-page-type.js';
-export * from './compare-pdfs-type.js';
-export * from './fix-page-size-type.js';
-export * from './view-metadata-type.js';
-export * from './header-footer-type.js';
-export * from './encrypt-pdf-type.js';
-export * from './flatten-pdf-type.js';
-export * from './crop-pdf-type.js';
-export * from './background-color-type.js';
-export * from './posterize-type.js';
-export * from './decrypt-pdf-type.js';
-export * from './combine-single-page-type.js';
-export * from './change-permissions-type.js';
-export * from './validate-signature-type.js';
-export * from './remove-restrictions-type.js';
-export * from './page-dimensions-type.js';
-export * from './extract-attachments-type.js';
-export * from './pdf-multi-tool-type.js';
-export * from './ocr-pdf-type.js';
-export * from './delete-pages-type.js';
-export * from './invert-colors-type.js';
-export * from './table-of-contents-type.js';
-export * from './organize-pdf-type.js';
-export * from './merge-pdf-type.js';
-export * from './extract-images-type.js';
-export * from './extract-pages-type.js';
-export * from './pdf-layers-type.js';
-export * from './sanitize-pdf-type.js';
-export * from './reverse-pages-type.js';
-export * from './text-color-type.js';
-export * from './n-up-pdf-type.js';
-export * from './linearize-pdf-type.js';
-export * from './remove-metadata-type.js';
-export * from './rotate-pdf-type.js';
-export * from './pdf-booklet-type.js';
-export * from './page-numbers-type.js';
-export * from './pdf-to-zip-type.js';
-export * from './sign-pdf-type.js';
-export * from './add-watermark-type.js';
-
+export * from './ocr-type.ts';
+export * from './form-creator-type.ts';
+export * from './digital-sign-type.ts';
+export * from './attachment-type.ts';
+export * from './edit-attachments-type.ts';
+export * from './edit-metadata-type.ts';
+export * from './divide-pages-type.ts';
+export * from './alternate-merge-page-type.ts';
+export * from './add-blank-page-type.ts';
+export * from './compare-pdfs-type.ts';
+export * from './fix-page-size-type.ts';
+export * from './view-metadata-type.ts';
+export * from './header-footer-type.ts';
+export * from './encrypt-pdf-type.ts';
+export * from './flatten-pdf-type.ts';
+export * from './crop-pdf-type.ts';
+export * from './background-color-type.ts';
+export * from './posterize-type.ts';
+export * from './decrypt-pdf-type.ts';
+export * from './combine-single-page-type.ts';
+export * from './change-permissions-type.ts';
+export * from './validate-signature-type.ts';
+export * from './remove-restrictions-type.ts';
+export * from './page-dimensions-type.ts';
+export * from './extract-attachments-type.ts';
+export * from './pdf-multi-tool-type.ts';
+export * from './ocr-pdf-type.ts';
+export * from './delete-pages-type.ts';
+export * from './invert-colors-type.ts';
+export * from './table-of-contents-type.ts';
+export * from './organize-pdf-type.ts';
+export * from './merge-pdf-type.ts';
+export * from './extract-images-type.ts';
+export * from './extract-pages-type.ts';
+export * from './pdf-layers-type.ts';
+export * from './sanitize-pdf-type.ts';
+export * from './reverse-pages-type.ts';
+export * from './text-color-type.ts';
+export * from './n-up-pdf-type.ts';
+export * from './linearize-pdf-type.ts';
+export * from './remove-metadata-type.ts';
+export * from './rotate-pdf-type.ts';
+export * from './pdf-booklet-type.ts';
+export * from './page-numbers-type.ts';
+export * from './pdf-to-zip-type.ts';
+export * from './sign-pdf-type.ts';
+export * from './add-watermark-type.ts';
+export * from './email-to-pdf-type.ts';
+export * from './bookmark-pdf-type.ts';
diff --git a/src/js/types/ocr-pdf-type.ts b/src/js/types/ocr-pdf-type.ts
index b1ef7e401..00a340d91 100644
--- a/src/js/types/ocr-pdf-type.ts
+++ b/src/js/types/ocr-pdf-type.ts
@@ -1,10 +1,46 @@
export interface OcrWord {
- text: string;
- bbox: { x0: number; y0: number; x1: number; y1: number };
- confidence: number;
+ text: string;
+ bbox: { x0: number; y0: number; x1: number; y1: number };
+ confidence: number;
}
export interface OcrState {
- file: File | null;
- searchablePdfBytes: Uint8Array | null;
+ file: File | null;
+ searchablePdfBytes: Uint8Array | null;
+}
+
+export interface BBox {
+ x0: number; // left
+ y0: number; // top (in hOCR coordinate system, origin at top-left)
+ x1: number; // right
+ y1: number; // bottom
+}
+
+export interface Baseline {
+ slope: number;
+ intercept: number;
+}
+
+export interface OcrLine {
+ bbox: BBox;
+ baseline: Baseline;
+ textangle: number;
+ words: OcrWord[];
+ direction: 'ltr' | 'rtl';
+ injectWordBreaks: boolean;
+}
+
+export interface OcrPage {
+ width: number;
+ height: number;
+ dpi: number;
+ lines: OcrLine[];
+}
+
+export interface WordTransform {
+ x: number;
+ y: number;
+ fontSize: number;
+ horizontalScale: number;
+ rotation: number;
}
diff --git a/src/js/utils/ghostscript-loader.ts b/src/js/utils/ghostscript-loader.ts
index e08e10a9e..1f5638493 100644
--- a/src/js/utils/ghostscript-loader.ts
+++ b/src/js/utils/ghostscript-loader.ts
@@ -42,7 +42,7 @@ export async function convertToPdfA(
gs = cachedGsModule;
} else {
const gsBaseUrl = getWasmBaseUrl('ghostscript');
- gs = await loadWASM({
+ gs = (await loadWASM({
locateFile: (path: string) => {
if (path.endsWith('.wasm')) {
return gsBaseUrl + 'gs.wasm';
@@ -51,7 +51,7 @@ export async function convertToPdfA(
},
print: (text: string) => console.log('[GS]', text),
printErr: (text: string) => console.error('[GS Error]', text),
- }) as GhostscriptModule;
+ })) as GhostscriptModule;
cachedGsModule = gs;
}
@@ -76,16 +76,24 @@ export async function convertToPdfA(
const response = await fetchWasmFile('ghostscript', iccFileName);
if (!response.ok) {
- throw new Error(`Failed to fetch ICC profile: ${iccFileName}. Ensure it is in your assets folder.`);
+ throw new Error(
+ `Failed to fetch ICC profile: ${iccFileName}. Ensure it is in your assets folder.`
+ );
}
const iccData = new Uint8Array(await response.arrayBuffer());
- console.log('[Ghostscript] sRGB v2 ICC profile loaded:', iccData.length, 'bytes');
+ console.log(
+ '[Ghostscript] sRGB v2 ICC profile loaded:',
+ iccData.length,
+ 'bytes'
+ );
gs.FS.writeFile(iccPath, iccData);
console.log('[Ghostscript] sRGB ICC profile written to FS:', iccPath);
- const iccHex = Array.from(iccData).map(b => b.toString(16).padStart(2, '0')).join('');
+ const iccHex = Array.from(iccData)
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('');
console.log('[Ghostscript] ICC profile hex length:', iccHex.length);
const pdfaSubtype = level === 'PDF/A-1b' ? '/GTS_PDFA1' : '/GTS_PDFA';
@@ -114,7 +122,9 @@ export async function convertToPdfA(
`;
gs.FS.writeFile(pdfaDefPath, pdfaPS);
- console.log('[Ghostscript] PDFA PostScript created with embedded ICC hex data');
+ console.log(
+ '[Ghostscript] PDFA PostScript created with embedded ICC hex data'
+ );
} catch (e) {
console.error('[Ghostscript] Failed to setup PDF/A assets:', e);
throw new Error('Conversion failed: could not create PDF/A definition');
@@ -163,10 +173,26 @@ export async function convertToPdfA(
console.log('[Ghostscript] Exit code:', exitCode);
if (exitCode !== 0) {
- try { gs.FS.unlink(inputPath); } catch { /* ignore */ }
- try { gs.FS.unlink(outputPath); } catch { /* ignore */ }
- try { gs.FS.unlink(iccPath); } catch { /* ignore */ }
- try { gs.FS.unlink(pdfaDefPath); } catch { /* ignore */ }
+ try {
+ gs.FS.unlink(inputPath);
+ } catch {
+ /* ignore */
+ }
+ try {
+ gs.FS.unlink(outputPath);
+ } catch {
+ /* ignore */
+ }
+ try {
+ gs.FS.unlink(iccPath);
+ } catch {
+ /* ignore */
+ }
+ try {
+ gs.FS.unlink(pdfaDefPath);
+ } catch {
+ /* ignore */
+ }
throw new Error(`Ghostscript conversion failed with exit code ${exitCode}`);
}
@@ -182,14 +208,32 @@ export async function convertToPdfA(
}
// Cleanup
- try { gs.FS.unlink(inputPath); } catch { /* ignore */ }
- try { gs.FS.unlink(outputPath); } catch { /* ignore */ }
- try { gs.FS.unlink(iccPath); } catch { /* ignore */ }
- try { gs.FS.unlink(pdfaDefPath); } catch { /* ignore */ }
+ try {
+ gs.FS.unlink(inputPath);
+ } catch {
+ /* ignore */
+ }
+ try {
+ gs.FS.unlink(outputPath);
+ } catch {
+ /* ignore */
+ }
+ try {
+ gs.FS.unlink(iccPath);
+ } catch {
+ /* ignore */
+ }
+ try {
+ gs.FS.unlink(pdfaDefPath);
+ } catch {
+ /* ignore */
+ }
if (level !== 'PDF/A-1b') {
onProgress?.('Post-processing for transparency compliance...');
- console.log('[Ghostscript] Adding Group dictionaries to pages for transparency compliance...');
+ console.log(
+ '[Ghostscript] Adding Group dictionaries to pages for transparency compliance...'
+ );
try {
output = await addPageGroupDictionaries(output);
@@ -202,10 +246,12 @@ export async function convertToPdfA(
return output;
}
-async function addPageGroupDictionaries(pdfData: Uint8Array): Promise {
+async function addPageGroupDictionaries(
+ pdfData: Uint8Array
+): Promise {
const pdfDoc = await PDFDocument.load(pdfData, {
ignoreEncryption: true,
- updateMetadata: false
+ updateMetadata: false,
});
const catalog = pdfDoc.catalog;
@@ -227,12 +273,22 @@ async function addPageGroupDictionaries(pdfData: Uint8Array): Promise {
- if (obj instanceof PDFDict || (obj && typeof obj === 'object' && 'dict' in obj)) {
- const dict = 'dict' in obj ? (obj as { dict: PDFDict }).dict : obj as PDFDict;
+ if (
+ obj instanceof PDFDict ||
+ (obj && typeof obj === 'object' && 'dict' in obj)
+ ) {
+ const dict =
+ 'dict' in obj ? (obj as { dict: PDFDict }).dict : (obj as PDFDict);
const subtype = dict.get(PDFName.of('Subtype'));
if (subtype instanceof PDFName && subtype.decodeText() === 'Form') {
@@ -290,8 +353,100 @@ export async function convertFileToPdfA(
const arrayBuffer = await file.arrayBuffer();
const pdfData = new Uint8Array(arrayBuffer);
const result = await convertToPdfA(pdfData, level, onProgress);
- // Copy to regular ArrayBuffer to avoid SharedArrayBuffer issues
const copy = new Uint8Array(result.length);
copy.set(result);
return new Blob([copy], { type: 'application/pdf' });
-}
\ No newline at end of file
+}
+
+export async function convertFontsToOutlines(
+ pdfData: Uint8Array,
+ onProgress?: (msg: string) => void
+): Promise {
+ onProgress?.('Loading Ghostscript...');
+
+ let gs: GhostscriptModule;
+
+ if (cachedGsModule) {
+ gs = cachedGsModule;
+ } else {
+ const gsBaseUrl = getWasmBaseUrl('ghostscript');
+ gs = (await loadWASM({
+ locateFile: (path: string) => {
+ if (path.endsWith('.wasm')) {
+ return gsBaseUrl + 'gs.wasm';
+ }
+ return path;
+ },
+ print: (text: string) => console.log('[GS]', text),
+ printErr: (text: string) => console.error('[GS Error]', text),
+ })) as GhostscriptModule;
+ cachedGsModule = gs;
+ }
+
+ const inputPath = '/tmp/input.pdf';
+ const outputPath = '/tmp/output.pdf';
+
+ gs.FS.writeFile(inputPath, pdfData);
+
+ onProgress?.('Converting fonts to outlines...');
+
+ const args = [
+ '-dNOSAFER',
+ '-dBATCH',
+ '-dNOPAUSE',
+ '-sDEVICE=pdfwrite',
+ '-dNoOutputFonts',
+ '-dCompressPages=true',
+ '-dAutoRotatePages=/None',
+ `-sOutputFile=${outputPath}`,
+ inputPath,
+ ];
+
+ let exitCode: number;
+ try {
+ exitCode = gs.callMain(args);
+ } catch (e) {
+ try {
+ gs.FS.unlink(inputPath);
+ } catch {}
+ throw new Error(`Ghostscript threw an exception: ${e}`);
+ }
+
+ if (exitCode !== 0) {
+ try {
+ gs.FS.unlink(inputPath);
+ } catch {}
+ try {
+ gs.FS.unlink(outputPath);
+ } catch {}
+ throw new Error(`Ghostscript conversion failed with exit code ${exitCode}`);
+ }
+
+ let output: Uint8Array;
+ try {
+ output = gs.FS.readFile(outputPath);
+ } catch (e) {
+ throw new Error('Ghostscript did not produce output file');
+ }
+
+ try {
+ gs.FS.unlink(inputPath);
+ } catch {}
+ try {
+ gs.FS.unlink(outputPath);
+ } catch {}
+
+ return output;
+}
+
+export async function convertFileToOutlines(
+ file: File,
+ onProgress?: (msg: string) => void
+): Promise {
+ const arrayBuffer = await file.arrayBuffer();
+ const pdfData = new Uint8Array(arrayBuffer);
+ const result = await convertFontsToOutlines(pdfData, onProgress);
+ const copy = new Uint8Array(result.length);
+ copy.set(result);
+ return new Blob([copy], { type: 'application/pdf' });
+}
diff --git a/src/js/utils/helpers.ts b/src/js/utils/helpers.ts
index 25e9cd4b0..b5afd5fa8 100644
--- a/src/js/utils/helpers.ts
+++ b/src/js/utils/helpers.ts
@@ -2,8 +2,7 @@ import createModule from '@neslinesli93/qpdf-wasm';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { createIcons } from 'lucide';
import { state, resetState } from '../state.js';
-import * as pdfjsLib from 'pdfjs-dist'
-
+import * as pdfjsLib from 'pdfjs-dist';
const STANDARD_SIZES = {
A4: { width: 595.28, height: 841.89 },
@@ -50,14 +49,14 @@ export function convertPoints(points: any, unit: any) {
// Convert hex color to RGB
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
- r: parseInt(result[1], 16) / 255,
- g: parseInt(result[2], 16) / 255,
- b: parseInt(result[3], 16) / 255,
- }
- : { r: 0, g: 0, b: 0 }
+ r: parseInt(result[1], 16) / 255,
+ g: parseInt(result[2], 16) / 255,
+ b: parseInt(result[3], 16) / 255,
+ }
+ : { r: 0, g: 0, b: 0 };
}
export const formatBytes = (bytes: any, decimals = 1) => {
@@ -89,7 +88,10 @@ export const readFileAsArrayBuffer = (file: any) => {
});
};
-export function parsePageRanges(rangeString: string, totalPages: number): number[] {
+export function parsePageRanges(
+ rangeString: string,
+ totalPages: number
+): number[] {
if (!rangeString || rangeString.trim() === '') {
return Array.from({ length: totalPages }, (_, i) => i);
}
@@ -128,11 +130,9 @@ export function parsePageRanges(rangeString: string, totalPages: number): number
}
}
-
return Array.from(indices).sort((a, b) => a - b);
}
-
/**
* Formats an ISO 8601 date string (e.g., "2008-02-21T17:15:56-08:00")
* into a localized, human-readable string.
@@ -198,7 +198,7 @@ export function formatStars(num: number) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toLocaleString();
-};
+}
/**
* Truncates a filename to a maximum length, adding ellipsis if needed.
@@ -207,14 +207,18 @@ export function formatStars(num: number) {
* @param maxLength - Maximum length (default: 30)
* @returns Truncated filename with ellipsis if needed
*/
-export function truncateFilename(filename: string, maxLength: number = 25): string {
+export function truncateFilename(
+ filename: string,
+ maxLength: number = 25
+): string {
if (filename.length <= maxLength) {
return filename;
}
const lastDotIndex = filename.lastIndexOf('.');
const extension = lastDotIndex !== -1 ? filename.substring(lastDotIndex) : '';
- const nameWithoutExt = lastDotIndex !== -1 ? filename.substring(0, lastDotIndex) : filename;
+ const nameWithoutExt =
+ lastDotIndex !== -1 ? filename.substring(0, lastDotIndex) : filename;
const availableLength = maxLength - extension.length - 3; // 3 for '...'
@@ -225,7 +229,10 @@ export function truncateFilename(filename: string, maxLength: number = 25): stri
return nameWithoutExt.substring(0, availableLength) + '...' + extension;
}
-export function formatShortcutDisplay(shortcut: string, isMac: boolean): string {
+export function formatShortcutDisplay(
+ shortcut: string,
+ isMac: boolean
+): string {
if (!shortcut) return '';
return shortcut
.replace('mod', isMac ? 'โ' : 'Ctrl')
@@ -233,7 +240,7 @@ export function formatShortcutDisplay(shortcut: string, isMac: boolean): string
.replace('alt', isMac ? 'โฅ' : 'Alt')
.replace('shift', 'Shift')
.split('+')
- .map(k => k.charAt(0).toUpperCase() + k.slice(1))
+ .map((k) => k.charAt(0).toUpperCase() + k.slice(1))
.join(isMac ? '' : '+');
}
@@ -263,7 +270,7 @@ export function resetAndReloadTool(preResetCallback?: () => void) {
export function getPDFDocument(src: any) {
let params = src;
- // Handle different input types similar to how getDocument handles them,
+ // Handle different input types similar to how getDocument handles them,
// but we ensure we have an object to attach wasmUrl to.
if (typeof src === 'string') {
params = { url: src };
@@ -283,3 +290,173 @@ export function getPDFDocument(src: any) {
wasmUrl: import.meta.env.BASE_URL + 'pdfjs-viewer/wasm/',
});
}
+
+/**
+ * Escape HTML special characters to prevent XSS
+ * @param text - The text to escape
+ * @returns The escaped text
+ */
+export function escapeHtml(text: string): string {
+ const map: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ };
+ return text.replace(/[&<>"']/g, (m) => map[m]);
+}
+
+export function uint8ArrayToBase64(bytes: Uint8Array): string {
+ const CHUNK_SIZE = 0x8000;
+ const chunks: string[] = [];
+ for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
+ const chunk = bytes.subarray(i, Math.min(i + CHUNK_SIZE, bytes.length));
+ chunks.push(String.fromCharCode(...chunk));
+ }
+ return btoa(chunks.join(''));
+}
+
+export function sanitizeEmailHtml(html: string): string {
+ if (!html) return html;
+
+ let sanitized = html;
+
+ sanitized = sanitized.replace(/]*>[\s\S]*?<\/head>/gi, '');
+ sanitized = sanitized.replace(/