= {
+ nw: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2',
+ ne: 'top-0 right-0 translate-x-1/2 -translate-y-1/2',
+ sw: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2',
+ se: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2',
+ n: 'top-0 left-1/2 -translate-x-1/2 -translate-y-1/2',
+ s: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2',
+ e: 'top-1/2 right-0 translate-x-1/2 -translate-y-1/2',
+ w: 'top-1/2 left-0 -translate-x-1/2 -translate-y-1/2',
+ }
+ handle.className += ` ${positions[pos]}`
+ handle.dataset.position = pos
+
+ handle.addEventListener('mousedown', (e) => {
+ e.stopPropagation()
+ startResize(e, field, pos)
+ })
+
+ // Touch events for resize handles
+ handle.addEventListener('touchstart', (e) => {
+ e.stopPropagation()
+ e.preventDefault()
+ const touch = e.touches[0]
+ // Create a synthetic mouse event for startResize
+ const syntheticEvent = {
+ clientX: touch.clientX,
+ clientY: touch.clientY,
+ preventDefault: () => { }
+ } as MouseEvent
+ startResize(syntheticEvent, field, pos)
+ })
+
+ fieldContainer.appendChild(handle)
+ })
+
+ canvas.appendChild(fieldWrapper)
+}
+
+function startResize(e: MouseEvent, field: FormField, pos: string): void {
+ resizing = true
+ resizeField = field
+ resizePos = pos
+ startX = e.clientX
+ startY = e.clientY
+ startWidth = field.width
+ startHeight = field.height
+ startLeft = field.x
+ startTop = field.y
+ e.preventDefault()
+}
+
+// Mouse move for dragging and resizing
+document.addEventListener('mousemove', (e) => {
+ if (draggedElement && !resizing) {
+ const rect = canvas.getBoundingClientRect()
+ let newX = e.clientX - rect.left - offsetX
+ let newY = e.clientY - rect.top - offsetY
+
+ newX = Math.max(0, Math.min(newX, 816 - draggedElement.offsetWidth))
+ newY = Math.max(0, Math.min(newY, 1056 - draggedElement.offsetHeight))
+
+ draggedElement.style.left = newX + 'px'
+ draggedElement.style.top = newY + 'px'
+
+ const field = fields.find((f) => f.id === draggedElement!.id)
+ if (field) {
+ field.x = newX
+ field.y = newY
+ }
+ } else if (resizing && resizeField) {
+ const dx = e.clientX - startX
+ const dy = e.clientY - startY
+ const fieldWrapper = document.getElementById(resizeField.id)
+
+ if (resizePos!.includes('e')) {
+ resizeField.width = Math.max(50, startWidth + dx)
+ }
+ if (resizePos!.includes('w')) {
+ const newWidth = Math.max(50, startWidth - dx)
+ const widthDiff = startWidth - newWidth
+ resizeField.width = newWidth
+ resizeField.x = startLeft + widthDiff
+ }
+ if (resizePos!.includes('s')) {
+ resizeField.height = Math.max(20, startHeight + dy)
+ }
+ if (resizePos!.includes('n')) {
+ const newHeight = Math.max(20, startHeight - dy)
+ const heightDiff = startHeight - newHeight
+ resizeField.height = newHeight
+ resizeField.y = startTop + heightDiff
+ }
+
+ if (fieldWrapper) {
+ const container = fieldWrapper.querySelector('.field-container') as HTMLElement
+ fieldWrapper.style.width = resizeField.width + 'px'
+ fieldWrapper.style.left = resizeField.x + 'px'
+ fieldWrapper.style.top = resizeField.y + 'px'
+ if (container) {
+ container.style.height = resizeField.height + 'px'
+ }
+ // Update combing visuals on resize
+ if (resizeField.combCells > 0) {
+ const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement
+ if (textEl) {
+ textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)`
+ textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)`
+ }
+ }
+ }
+ }
+})
+
+document.addEventListener('mouseup', () => {
+ draggedElement = null
+ resizing = false
+ resizeField = null
+})
+
+// Touch move for dragging and resizing
+document.addEventListener('touchmove', (e) => {
+ const touch = e.touches[0]
+ if (resizing && resizeField) {
+ const dx = touch.clientX - startX
+ const dy = touch.clientY - startY
+ const fieldWrapper = document.getElementById(resizeField.id)
+
+ if (resizePos!.includes('e')) {
+ resizeField.width = Math.max(50, startWidth + dx)
+ }
+ if (resizePos!.includes('w')) {
+ const newWidth = Math.max(50, startWidth - dx)
+ const widthDiff = startWidth - newWidth
+ resizeField.width = newWidth
+ resizeField.x = startLeft + widthDiff
+ }
+ if (resizePos!.includes('s')) {
+ resizeField.height = Math.max(20, startHeight + dy)
+ }
+ if (resizePos!.includes('n')) {
+ const newHeight = Math.max(20, startHeight - dy)
+ const heightDiff = startHeight - newHeight
+ resizeField.height = newHeight
+ resizeField.y = startTop + heightDiff
+ }
+
+ if (fieldWrapper) {
+ const container = fieldWrapper.querySelector('.field-container') as HTMLElement
+ fieldWrapper.style.width = resizeField.width + 'px'
+ fieldWrapper.style.left = resizeField.x + 'px'
+ fieldWrapper.style.top = resizeField.y + 'px'
+ if (container) {
+ container.style.height = resizeField.height + 'px'
+ }
+ if (resizeField.combCells > 0) {
+ const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement
+ if (textEl) {
+ textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)`
+ textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)`
+ }
+ }
+ }
+ }
+}, { passive: false })
+
+document.addEventListener('touchend', () => {
+ resizing = false
+ resizeField = null
+})
+
+
+
+// Select field
+function selectField(field: FormField): void {
+ deselectAll()
+ selectedField = field
+ const fieldWrapper = document.getElementById(field.id)
+ if (fieldWrapper) {
+ const container = fieldWrapper.querySelector('.field-container') as HTMLElement
+ const label = fieldWrapper.querySelector('.field-label') as HTMLElement
+ const handles = fieldWrapper.querySelectorAll('.resize-handle')
+
+ if (container) {
+ // Remove hover classes and add selected classes
+ container.classList.remove('border-indigo-200', 'group-hover:border-dashed', 'group-hover:border-indigo-300')
+ container.classList.add('border-dashed', 'border-indigo-500', 'bg-indigo-50')
+ }
+
+ if (label) {
+ label.classList.remove('opacity-0', 'group-hover:opacity-100')
+ label.classList.add('opacity-100')
+ }
+
+ handles.forEach(handle => {
+ handle.classList.remove('hidden')
+ })
+ }
+ showProperties(field)
+}
+
+// Deselect all
+function deselectAll(): void {
+ if (selectedField) {
+ const fieldWrapper = document.getElementById(selectedField.id)
+ if (fieldWrapper) {
+ const container = fieldWrapper.querySelector('.field-container') as HTMLElement
+ const label = fieldWrapper.querySelector('.field-label') as HTMLElement
+ const handles = fieldWrapper.querySelectorAll('.resize-handle')
+
+ if (container) {
+ // Revert to default/hover state
+ container.classList.remove('border-dashed', 'border-indigo-500', 'bg-indigo-50')
+ container.classList.add('border-indigo-200', 'group-hover:border-dashed', 'group-hover:border-indigo-300')
+ }
+
+ if (label) {
+ label.classList.remove('opacity-100')
+ label.classList.add('opacity-0', 'group-hover:opacity-100')
+ }
+
+ handles.forEach(handle => {
+ handle.classList.add('hidden')
+ })
+ }
+ selectedField = null
+ }
+ hideProperties()
+}
+
+// Show properties panel
+function showProperties(field: FormField): void {
+ let specificProps = ''
+
+ if (field.type === 'text') {
+ specificProps = `
+
+ Value
+ 0 ? `maxlength="${field.combCells}"` : field.maxLength > 0 ? `maxlength="${field.maxLength}"` : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
+
+
+ Max Length (0 for unlimited)
+ 0 ? 'disabled' : ''} class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500 disabled:opacity-50">
+
+
+ Divide into boxes (0 to disable)
+
+
+
+ Font Size
+
+
+
+ Text Color
+
+
+
+ Alignment
+
+ Left
+ Center
+ Right
+
+
+
+ Multi-line
+
+
+
+
+ `
+ } else if (field.type === 'checkbox') {
+ specificProps = `
+
+ Checked State
+
+
+
+
+ `
+ } else if (field.type === 'radio') {
+ specificProps = `
+
+ Group Name (Must be same for group)
+
+
+
+ Export Value
+
+
+
+ Checked State
+
+
+
+
+ `
+ } else if (field.type === 'dropdown' || field.type === 'optionlist') {
+ specificProps = `
+
+ Options (One per line or comma separated)
+
+
+
+ Selected Option
+
+ None
+ ${field.options?.map(opt => `${opt} `).join('')}
+
+
+
+ To actually fill or change the options, use our PDF Form Filler tool.
+
+ `
+ } else if (field.type === 'button') {
+ specificProps = `
+
+ Label
+
+
+
+ Action
+
+ None
+ Reset Form
+ Print Form
+ Open URL
+ Run Javascript
+ Show/Hide Field
+
+
+
+ URL
+
+
+
+ Javascript Code
+
+
+
+
+ Target Field
+
+ Select a field...
+ ${fields.filter(f => f.id !== field.id).map(f => `${f.name} (${f.type}) `).join('')}
+
+
+
+ Visibility
+
+ Show
+ Hide
+ Toggle
+
+
+
+ `
+ } else if (field.type === 'signature') {
+ specificProps = `
+
+ Signature fields are AcroForm signature fields and would only be visible in an advanced PDF viewer.
+
+ `
+ } else if (field.type === 'date') {
+ const formats = ['mm/dd/yyyy', 'dd/mm/yyyy', 'mm/yy', 'dd/mm/yy', 'yyyy/mm/dd', 'mmm d, yyyy', 'd-mmm-yy', 'yy-mm-dd']
+ specificProps = `
+
+ Date Format
+
+ ${formats.map(f => `${f} `).join('')}
+
+
+
+ The selected format will be enforced when the user types or picks a date.
+
+
+
+
+ Browser Note: Firefox and Chrome may show their native date picker format during selection. The correct format will apply when you finish entering the date. This is normal browser behavior and not an issue.
+
+
+ `
+ } else if (field.type === 'image') {
+ specificProps = `
+
+ Label / Prompt
+
+
+
+ Clicking this field in the PDF will open a file picker to upload an image.
+
+ `
+ }
+
+ propertiesPanel.innerHTML = `
+
+ `
+
+ // Common listeners
+ const propName = document.getElementById('propName') as HTMLInputElement
+ 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
+
+ propName.addEventListener('change', (e) => {
+ const newName = (e.target as HTMLInputElement).value.trim()
+
+ if (!newName) {
+ showModal('Invalid Name', 'Field name cannot be empty.', 'warning');
+ (e.target as HTMLInputElement).value = field.name
+ return
+ }
+
+ // Check for duplicate name
+ const isDuplicate = fields.some(f => f.id !== field.id && f.name === newName)
+
+ if (isDuplicate) {
+ showModal('Duplicate Name', `A field with the name "${newName}" already exists. Please choose a unique name.`, 'error');
+ (e.target as HTMLInputElement).value = field.name
+ return
+ }
+
+ 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
+ })
+
+ propRequired.addEventListener('change', (e) => {
+ field.required = (e.target as HTMLInputElement).checked
+ })
+
+ propReadOnly.addEventListener('change', (e) => {
+ field.readOnly = (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')
+ }
+
+ // 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 === '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 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
+ 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)
+ })
+ }
+ } 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
'
+}
+
+// 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()
+}
+
+// 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'
+ }
+})
+
+// Update field count
+function updateFieldCount(): void {
+ 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[] = []
+
+ fields.forEach(field => {
+ const count = nameCount.get(field.name) || 0
+ nameCount.set(field.name, count + 1)
+ })
+
+ nameCount.forEach((count, name) => {
+ if (count > 1) {
+ duplicates.push(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
+ }
+
+ 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 (uploadedPdfDoc) {
+ pdfDoc = uploadedPdfDoc
+ } else {
+ pdfDoc = await PDFDocument.create()
+
+ for (const pageData of pages) {
+ pdfDoc.addPage([pageData.width, pageData.height])
+ }
+ }
+
+ 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 / currentScale
+ const scaleY = 1 / currentScale
+
+ const x = field.x * scaleX
+ const y = pageHeight - field.y * scaleY - field.height * scaleY // PDF coordinates from bottom
+ const width = field.width * scaleX
+ const height = field.height * scaleY
+
+ if (field.type === 'text') {
+ const textField = form.createTextField(field.name)
+ const rgbColor = hexToRgb(field.textColor)
+
+ textField.addToPage(pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: 1,
+ borderColor: rgb(0, 0, 0),
+ 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)
+ checkBox.addToPage(pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: 1,
+ borderColor: rgb(0, 0, 0),
+ 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.groupName || 'RadioGroup1'
+ let radioGroup
+
+ if (radioGroups.has(groupName)) {
+ radioGroup = radioGroups.get(groupName)
+ } else {
+ radioGroup = form.createRadioGroup(groupName)
+ radioGroups.set(groupName, radioGroup)
+ }
+
+ radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: 1,
+ borderColor: rgb(0, 0, 0),
+ 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)
+ dropdown.addToPage(pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: 1,
+ borderColor: rgb(0, 0, 0),
+ 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)
+ optionList.addToPage(pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: 1,
+ borderColor: rgb(0, 0, 0),
+ 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)
+ button.addToPage(field.label || 'Button', pdfPage, {
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ borderWidth: 1,
+ borderColor: rgb(0, 0, 0),
+ 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 (actionDict) {
+ widget.dict.set(PDFName.of('A'), actionDict)
+ }
+ })
+ }
+
+ 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
+ const widgets = dateField.acroField.getWidgets()
+ const dateFormat = field.dateFormat || 'mm/dd/yyyy'
+ widgets.forEach(widget => {
+ 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}");`)
+ })
+
+ // Set as Format action (AA / F) and Keystroke action (AA / K)
+ const additionalActions = pdfDoc.context.obj({
+ F: formatAction,
+ K: keystrokeAction
+ })
+ widget.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')
+ } 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'
+ 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 = '/'
+ })
+})
+
+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] }
+}
+
+// Reset to initial state
+function resetToInitial(): void {
+ fields = []
+ pages = []
+ currentPageIndex = 0
+ uploadedPdfDoc = null
+ selectedField = null
+
+ canvas.innerHTML = ''
+
+ propertiesPanel.innerHTML = 'Select a field to edit properties
'
+
+ updateFieldCount()
+
+ // 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()
+}
+
+function switchToPage(pageIndex: number): void {
+ if (pageIndex < 0 || pageIndex >= pages.length) return
+
+ currentPageIndex = pageIndex
+ renderCanvas()
+ updatePageNavigation()
+
+ // Deselect any selected field when switching pages
+ deselectAll()
+}
+
+// Render the canvas for the current page
+function renderCanvas(): void {
+ const currentPage = pages[currentPageIndex]
+ if (!currentPage) return
+
+ // Fixed scale for better visibility
+ let scale = 1.333
+
+ currentScale = scale
+
+ const canvasWidth = currentPage.width * scale
+ const canvasHeight = currentPage.height * scale
+
+ canvas.style.width = `${canvasWidth}px`
+ canvas.style.height = `${canvasHeight}px`
+
+ canvas.innerHTML = ''
+
+ if (currentPage.pdfPageData) {
+ const img = document.createElement('img')
+ img.src = currentPage.pdfPageData as string
+ img.style.position = 'absolute'
+ img.style.top = '0'
+ img.style.left = '0'
+ img.style.width = '100%'
+ img.style.height = '100%'
+ img.style.pointerEvents = 'none'
+ img.style.opacity = '0.8' // Slightly transparent so fields are visible
+ canvas.appendChild(img)
+ }
+
+ // Render fields for current page
+ 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
+}
+
+// Drag and drop handlers for upload area
+dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault()
+ dropZone.classList.add('border-indigo-500', 'bg-gray-600')
+})
+
+dropZone.addEventListener('dragleave', () => {
+ 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])
+ }
+})
+
+pdfFileInput.addEventListener('change', async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0]
+ if (file) {
+ handlePdfUpload(file)
+ }
+})
+
+blankPdfBtn.addEventListener('click', () => {
+ pageSizeSelector.classList.remove('hidden')
+})
+
+pageSizeSelect.addEventListener('change', () => {
+ if (pageSizeSelect.value === 'custom') {
+ customDimensionsInput.classList.remove('hidden')
+ } else {
+ customDimensionsInput.classList.add('hidden')
+ }
+})
+
+confirmBlankBtn.addEventListener('click', () => {
+ const selectedSize = pageSizeSelect.value
+ pageSize = getPageDimensions(selectedSize)
+
+ createBlankPage()
+ switchToPage(0)
+
+ // 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)
+
+ // Check for existing fields and update counter
+ try {
+ const form = uploadedPdfDoc.getForm()
+ const fields = form.getFields()
+ fields.forEach(field => {
+ const name = field.getName()
+ // Check if name matches pattern Type_Number
+ const match = name.match(/([a-zA-Z]+)_(\d+)/)
+ if (match) {
+ const num = parseInt(match[2])
+ if (!isNaN(num) && num >= fieldCounter) {
+ fieldCounter = num
+ }
+ }
+ })
+ } catch (e) {
+ // No form or error getting fields, ignore
+ }
+
+ const pdfjsDoc = await getPDFDocument({ data: arrayBuffer }).promise
+
+ const pageCount = uploadedPdfDoc.getPageCount()
+ pages = []
+
+ for (let i = 0; i < pageCount; i++) {
+ const page = uploadedPdfDoc.getPage(i)
+ const { width, height } = page.getSize()
+
+ const pdfjsPage = await pdfjsDoc.getPage(i + 1)
+ const viewport = pdfjsPage.getViewport({ scale: 1.333 })
+
+ const tempCanvas = document.createElement('canvas')
+ tempCanvas.width = viewport.width
+ tempCanvas.height = viewport.height
+
+ const context = tempCanvas.getContext('2d')!
+ await pdfjsPage.render({
+ canvasContext: context,
+ viewport,
+ canvas: tempCanvas,
+ }).promise
+
+ const dataUrl = tempCanvas.toDataURL('image/png')
+
+ pages.push({
+ index: i,
+ width,
+ height,
+ pdfPageData: dataUrl as any
+ })
+ }
+
+ currentPageIndex = 0
+ renderCanvas()
+ updatePageNavigation()
+
+ // 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)
+ }
+})
+
+nextPageBtn.addEventListener('click', () => {
+ if (currentPageIndex < pages.length - 1) {
+ switchToPage(currentPageIndex + 1)
+ }
+})
+
+addPageBtn.addEventListener('click', () => {
+ 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()
+ }
+})
+
+// Custom Modal Logic
+const errorModal = document.getElementById('errorModal')
+const errorModalTitle = document.getElementById('errorModalTitle')
+const errorModalMessage = document.getElementById('errorModalMessage')
+const errorModalClose = document.getElementById('errorModalClose')
+
+function showModal(title: string, message: string, type: 'error' | 'warning' | 'info' = 'error') {
+ if (!errorModal || !errorModalTitle || !errorModalMessage) return
+
+ errorModalTitle.textContent = title
+ errorModalMessage.textContent = message
+ errorModal.classList.remove('hidden')
+}
+
+if (errorModalClose) {
+ errorModalClose.addEventListener('click', () => {
+ errorModal?.classList.add('hidden')
+ })
+}
+
+// Close modal on backdrop click
+if (errorModal) {
+ errorModal.addEventListener('click', (e) => {
+ if (e.target === errorModal) {
+ errorModal.classList.add('hidden')
+ }
+ })
+}
+
+initializeGlobalShortcuts()
diff --git a/src/js/logic/invert-colors.ts b/src/js/logic/invert-colors.ts
index 13b202d..ac29376 100644
--- a/src/js/logic/invert-colors.ts
+++ b/src/js/logic/invert-colors.ts
@@ -1,7 +1,11 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile } from '../utils/helpers.js';
+import { downloadFile, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
+import * as pdfjsLib from 'pdfjs-dist';
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
export async function invertColors() {
if (!state.pdfDoc) {
@@ -12,8 +16,7 @@ export async function invertColors() {
try {
const newPdfDoc = await PDFLibDocument.create();
const pdfBytes = await state.pdfDoc.save();
- // @ts-expect-error TS(2304) FIXME: Cannot find name 'pdfjsLib'.
- const pdfjsDoc = await pdfjsLib.getDocument({ data: pdfBytes }).promise;
+ const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise;
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
const page = await pdfjsDoc.getPage(i);
@@ -22,7 +25,7 @@ export async function invertColors() {
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
- await page.render({ canvasContext: ctx, viewport }).promise;
+ await page.render({ canvasContext: ctx, viewport, canvas }).promise;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
diff --git a/src/js/logic/merge.ts b/src/js/logic/merge.ts
index 44da64a..9cb7212 100644
--- a/src/js/logic/merge.ts
+++ b/src/js/logic/merge.ts
@@ -1,5 +1,5 @@
import { showLoader, hideLoader, showAlert } from '../ui.ts';
-import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.ts';
+import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.ts';
import { state } from '../state.ts';
import { renderPagesProgressively, cleanupLazyRendering, createPlaceholder } from '../utils/render-utils.ts';
@@ -8,10 +8,7 @@ import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import Sortable from 'sortablejs';
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
-).toString();
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
const mergeState = {
pdfDocs: {},
@@ -183,7 +180,7 @@ async function renderPageMergeThumbnails() {
if (!pdfDoc) continue;
const pdfData = await pdfDoc.save();
- const pdfjsDoc = await pdfjsLib.getDocument({ data: pdfData }).promise;
+ const pdfjsDoc = await getPDFDocument({ data: pdfData }).promise;
// Create a wrapper function that includes the file name
const createWrapperWithFileName = (canvas: HTMLCanvasElement, pageNumber: number) => {
diff --git a/src/js/logic/ocr-pdf.ts b/src/js/logic/ocr-pdf.ts
index 4198e8c..303b09f 100644
--- a/src/js/logic/ocr-pdf.ts
+++ b/src/js/logic/ocr-pdf.ts
@@ -1,10 +1,14 @@
import { tesseractLanguages } from '../config/tesseract-languages.js';
import { showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
+import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import Tesseract from 'tesseract.js';
import { PDFDocument as PDFLibDocument, StandardFonts, rgb } from 'pdf-lib';
import { icons, createIcons } from 'lucide';
+import * as pdfjsLib from 'pdfjs-dist';
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
let searchablePdfBytes: any = null;
@@ -117,8 +121,7 @@ async function runOCR() {
tessedit_char_whitelist: whitelist,
});
- // @ts-expect-error TS(2304) FIXME: Cannot find name 'pdfjsLib'.
- const pdf = await pdfjsLib.getDocument(
+ const pdf = await getPDFDocument(
await readFileAsArrayBuffer(state.files[0])
).promise;
const newPdfDoc = await PDFLibDocument.create();
@@ -136,7 +139,7 @@ async function runOCR() {
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d');
- await page.render({ canvasContext: context, viewport }).promise;
+ await page.render({ canvasContext: context, viewport, canvas }).promise;
if (binarize) {
binarizeCanvas(context);
diff --git a/src/js/logic/page-dimensions.ts b/src/js/logic/page-dimensions.ts
index bd53d65..4fdc23a 100644
--- a/src/js/logic/page-dimensions.ts
+++ b/src/js/logic/page-dimensions.ts
@@ -1,8 +1,120 @@
import { state } from '../state.js';
import { getStandardPageName, convertPoints } from '../utils/helpers.js';
+import { icons, createIcons } from 'lucide';
let analyzedPagesData: any = []; // Store raw data to avoid re-analyzing
+function calculateAspectRatio(width: number, height: number): string {
+ const ratio = width / height;
+ return ratio.toFixed(3);
+}
+
+function calculateArea(width: number, height: number, unit: string): string {
+ const areaInPoints = width * height;
+ let convertedArea = 0;
+ let unitSuffix = '';
+
+ switch (unit) {
+ case 'in':
+ convertedArea = areaInPoints / (72 * 72); // 72 points per inch
+ unitSuffix = 'in²';
+ break;
+ case 'mm':
+ convertedArea = areaInPoints / (72 * 72) * (25.4 * 25.4); // Convert to mm²
+ unitSuffix = 'mm²';
+ break;
+ case 'px':
+ const pxPerPoint = 96 / 72;
+ convertedArea = areaInPoints * (pxPerPoint * pxPerPoint);
+ unitSuffix = 'px²';
+ break;
+ default: // 'pt'
+ convertedArea = areaInPoints;
+ unitSuffix = 'pt²';
+ break;
+ }
+
+ return `${convertedArea.toFixed(2)} ${unitSuffix}`;
+}
+
+
+function getSummaryStats() {
+ const totalPages = analyzedPagesData.length;
+
+ // Count unique page sizes
+ const uniqueSizes = new Map();
+ analyzedPagesData.forEach((pageData: any) => {
+ const key = `${pageData.width.toFixed(2)}x${pageData.height.toFixed(2)}`;
+ const label = `${pageData.standardSize} (${pageData.orientation})`;
+ uniqueSizes.set(key, {
+ count: (uniqueSizes.get(key)?.count || 0) + 1,
+ label: label,
+ width: pageData.width,
+ height: pageData.height
+ });
+ });
+
+ const hasMixedSizes = uniqueSizes.size > 1;
+
+ return {
+ totalPages,
+ uniqueSizesCount: uniqueSizes.size,
+ uniqueSizes: Array.from(uniqueSizes.values()),
+ hasMixedSizes
+ };
+}
+
+function renderSummary() {
+ const summaryContainer = document.getElementById('dimensions-summary');
+ if (!summaryContainer) return;
+
+ const stats = getSummaryStats();
+
+ let summaryHTML = `
+
+
+
Total Pages
+
${stats.totalPages}
+
+
+
Unique Page Sizes
+
${stats.uniqueSizesCount}
+
+
+
Document Type
+
+ ${stats.hasMixedSizes ? 'Mixed Sizes' : 'Uniform'}
+
+
+
+ `;
+
+ if (stats.hasMixedSizes) {
+ summaryHTML += `
+
+
+
+
+
Mixed Page Sizes Detected
+
This document contains pages with different dimensions:
+
+ ${stats.uniqueSizes.map((size: any) => `
+ • ${size.label}: ${size.count} page${size.count > 1 ? 's' : ''}
+ `).join('')}
+
+
+
+
+ `;
+ }
+
+ summaryContainer.innerHTML = summaryHTML;
+
+ if (stats.hasMixedSizes) {
+ createIcons({ icons });
+ }
+}
+
/**
* Renders the dimensions table based on the stored data and selected unit.
* @param {string} unit The unit to display dimensions in ('pt', 'in', 'mm', 'px').
@@ -16,31 +128,89 @@ function renderTable(unit: any) {
analyzedPagesData.forEach((pageData) => {
const width = convertPoints(pageData.width, unit);
const height = convertPoints(pageData.height, unit);
+ const aspectRatio = calculateAspectRatio(pageData.width, pageData.height);
+ const area = calculateArea(pageData.width, pageData.height, unit);
const row = document.createElement('tr');
- // Create and append each cell safely using textContent
+ // Page number
const pageNumCell = document.createElement('td');
pageNumCell.className = 'px-4 py-3 text-white';
pageNumCell.textContent = pageData.pageNum;
+ // Dimensions
const dimensionsCell = document.createElement('td');
dimensionsCell.className = 'px-4 py-3 text-gray-300';
dimensionsCell.textContent = `${width} x ${height} ${unit}`;
+ // Standard size
const sizeCell = document.createElement('td');
sizeCell.className = 'px-4 py-3 text-gray-300';
sizeCell.textContent = pageData.standardSize;
+ // Orientation
const orientationCell = document.createElement('td');
orientationCell.className = 'px-4 py-3 text-gray-300';
orientationCell.textContent = pageData.orientation;
- row.append(pageNumCell, dimensionsCell, sizeCell, orientationCell);
+ // Aspect Ratio
+ const aspectRatioCell = document.createElement('td');
+ aspectRatioCell.className = 'px-4 py-3 text-gray-300';
+ aspectRatioCell.textContent = aspectRatio;
+
+ // Area
+ const areaCell = document.createElement('td');
+ areaCell.className = 'px-4 py-3 text-gray-300';
+ areaCell.textContent = area;
+
+ // Rotation
+ const rotationCell = document.createElement('td');
+ rotationCell.className = 'px-4 py-3 text-gray-300';
+ rotationCell.textContent = `${pageData.rotation}°`;
+
+ row.append(pageNumCell, dimensionsCell, sizeCell, orientationCell, aspectRatioCell, areaCell, rotationCell);
tableBody.appendChild(row);
});
}
+function exportToCSV() {
+ const unitsSelect = document.getElementById('units-select') as HTMLSelectElement;
+ const unit = unitsSelect?.value || 'pt';
+
+ const headers = ['Page #', `Width (${unit})`, `Height (${unit})`, 'Standard Size', 'Orientation', 'Aspect Ratio', `Area (${unit}²)`, 'Rotation'];
+ const csvRows = [headers.join(',')];
+
+ analyzedPagesData.forEach((pageData: any) => {
+ const width = convertPoints(pageData.width, unit);
+ const height = convertPoints(pageData.height, unit);
+ const aspectRatio = calculateAspectRatio(pageData.width, pageData.height);
+ const area = calculateArea(pageData.width, pageData.height, unit);
+
+ const row = [
+ pageData.pageNum,
+ width,
+ height,
+ pageData.standardSize,
+ pageData.orientation,
+ aspectRatio,
+ area,
+ `${pageData.rotation}°`
+ ];
+ csvRows.push(row.join(','));
+ });
+
+ const csvContent = csvRows.join('\n');
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'page-dimensions.csv';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
+
/**
* Main function to analyze the PDF and display dimensions.
* This is called once after the file is loaded.
@@ -53,28 +223,36 @@ export function analyzeAndDisplayDimensions() {
pages.forEach((page: any, index: any) => {
const { width, height } = page.getSize();
+ const rotation = page.getRotation().angle || 0;
+
analyzedPagesData.push({
pageNum: index + 1,
width, // Store raw width in points
height, // Store raw height in points
orientation: width > height ? 'Landscape' : 'Portrait',
standardSize: getStandardPageName(width, height),
+ rotation: rotation
});
});
const resultsContainer = document.getElementById('dimensions-results');
const unitsSelect = document.getElementById('units-select');
+ renderSummary();
+
// Initial render with default unit (points)
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
renderTable(unitsSelect.value);
- // Show the results table
resultsContainer.classList.remove('hidden');
- // Add event listener to handle unit changes
unitsSelect.addEventListener('change', (e) => {
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'EventTarg... Remove this comment to see the full error message
renderTable(e.target.value);
});
+
+ const exportButton = document.getElementById('export-csv-btn');
+ if (exportButton) {
+ exportButton.addEventListener('click', exportToCSV);
+ }
}
diff --git a/src/js/logic/pdf-multi-tool.ts b/src/js/logic/pdf-multi-tool.ts
index 850d22d..3bd5906 100644
--- a/src/js/logic/pdf-multi-tool.ts
+++ b/src/js/logic/pdf-multi-tool.ts
@@ -3,7 +3,7 @@ import { degrees, PDFDocument as PDFLibDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import JSZip from 'jszip';
import Sortable from 'sortablejs';
-import { downloadFile } from '../utils/helpers';
+import { downloadFile, getPDFDocument } from '../utils/helpers';
import { renderPagesProgressively, cleanupLazyRendering, renderPageToCanvas, createPlaceholder } from '../utils/render-utils';
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
@@ -357,7 +357,7 @@ async function loadPdfs(files: File[]) {
const pdfIndex = currentPdfDocs.length - 1;
const pdfBytes = await pdfDoc.save();
- const pdfjsDoc = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise;
+ const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise;
const numPages = pdfjsDoc.numPages;
// Pre-fill allPages with placeholders to maintain order/state
@@ -741,7 +741,7 @@ async function handleInsertPdf(e: Event) {
// Load PDF.js document for rendering
const pdfBytes = await pdfDoc.save();
- const pdfjsDoc = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise;
+ const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise;
const numPages = pdfjsDoc.numPages;
const newPages: PageData[] = [];
diff --git a/src/js/logic/pdf-to-bmp.ts b/src/js/logic/pdf-to-bmp.ts
index 87984d8..e07a066 100644
--- a/src/js/logic/pdf-to-bmp.ts
+++ b/src/js/logic/pdf-to-bmp.ts
@@ -1,7 +1,10 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
+import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import JSZip from 'jszip';
+import * as pdfjsLib from 'pdfjs-dist';
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
/**
* Creates a BMP file buffer from raw pixel data (ImageData).
@@ -53,8 +56,7 @@ function encodeBMP(imageData: any) {
export async function pdfToBmp() {
showLoader('Converting PDF to BMP images...');
try {
- // @ts-expect-error TS(2304) FIXME: Cannot find name 'pdfjsLib'.
- const pdf = await pdfjsLib.getDocument(
+ const pdf = await getPDFDocument(
await readFileAsArrayBuffer(state.files[0])
).promise;
const zip = new JSZip();
@@ -69,7 +71,7 @@ export async function pdfToBmp() {
canvas.width = viewport.width;
// Render the PDF page directly to the canvas
- await page.render({ canvasContext: context, viewport: viewport }).promise;
+ await page.render({ canvasContext: context, viewport: viewport, canvas }).promise;
// Get the raw pixel data from this canvas
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
diff --git a/src/js/logic/pdf-to-greyscale.ts b/src/js/logic/pdf-to-greyscale.ts
index c75f8c3..1013096 100644
--- a/src/js/logic/pdf-to-greyscale.ts
+++ b/src/js/logic/pdf-to-greyscale.ts
@@ -1,9 +1,11 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile } from '../utils/helpers.js';
+import { downloadFile, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
-
+import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
export async function pdfToGreyscale() {
if (!state.pdfDoc) {
showAlert('Error', 'PDF not loaded.');
@@ -13,8 +15,7 @@ export async function pdfToGreyscale() {
try {
const newPdfDoc = await PDFLibDocument.create();
const pdfBytes = await state.pdfDoc.save();
- // @ts-expect-error TS(2304) FIXME: Cannot find name 'pdfjsLib'.
- const pdfjsDoc = await pdfjsLib.getDocument({ data: pdfBytes }).promise;
+ const pdfjsDoc = await getPDFDocument({ data: pdfBytes }).promise;
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
const page = await pdfjsDoc.getPage(i);
@@ -24,7 +25,7 @@ export async function pdfToGreyscale() {
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
- await page.render({ canvasContext: ctx, viewport }).promise;
+ await page.render({ canvasContext: ctx, viewport, canvas }).promise;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
diff --git a/src/js/logic/pdf-to-jpg.ts b/src/js/logic/pdf-to-jpg.ts
index 5e53a13..a12c102 100644
--- a/src/js/logic/pdf-to-jpg.ts
+++ b/src/js/logic/pdf-to-jpg.ts
@@ -1,13 +1,16 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
+import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import JSZip from 'jszip';
+import * as pdfjsLib from 'pdfjs-dist';
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
export async function pdfToJpg() {
showLoader('Converting to JPG...');
try {
- // @ts-expect-error TS(2304) FIXME: Cannot find name 'pdfjsLib'.
- const pdf = await pdfjsLib.getDocument(
+ const pdf = await getPDFDocument(
await readFileAsArrayBuffer(state.files[0])
).promise;
const zip = new JSZip();
@@ -23,7 +26,7 @@ export async function pdfToJpg() {
canvas.height = viewport.height;
canvas.width = viewport.width;
- await page.render({ canvasContext: context, viewport: viewport }).promise;
+ await page.render({ canvasContext: context, viewport: viewport, canvas }).promise;
const blob = await new Promise((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', quality)
diff --git a/src/js/logic/pdf-to-markdown.ts b/src/js/logic/pdf-to-markdown.ts
index 69610eb..4ea06dc 100644
--- a/src/js/logic/pdf-to-markdown.ts
+++ b/src/js/logic/pdf-to-markdown.ts
@@ -1,5 +1,5 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
+import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
export async function pdfToMarkdown() {
@@ -7,8 +7,7 @@ export async function pdfToMarkdown() {
try {
const file = state.files[0];
const arrayBuffer = await readFileAsArrayBuffer(file);
- // @ts-expect-error TS(2304) FIXME: Cannot find name 'pdfjsLib'.
- const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
+ const pdf = await getPDFDocument({ data: arrayBuffer }).promise;
let markdown = '';
for (let i = 1; i <= pdf.numPages; i++) {
diff --git a/src/js/logic/pdf-to-png.ts b/src/js/logic/pdf-to-png.ts
index 7207493..ef95173 100644
--- a/src/js/logic/pdf-to-png.ts
+++ b/src/js/logic/pdf-to-png.ts
@@ -1,20 +1,23 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
+import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import JSZip from 'jszip';
+import * as pdfjsLib from 'pdfjs-dist';
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
export async function pdfToPng() {
showLoader('Converting to PNG...');
try {
- // @ts-expect-error TS(2304) FIXME: Cannot find name 'pdfjsLib'.
- const pdf = await pdfjsLib.getDocument(
+ const pdf = await getPDFDocument(
await readFileAsArrayBuffer(state.files[0])
).promise;
const zip = new JSZip();
-
+
const qualityInput = document.getElementById('png-quality') as HTMLInputElement;
const scale = qualityInput ? parseFloat(qualityInput.value) : 2.0;
-
+
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale });
@@ -22,7 +25,7 @@ export async function pdfToPng() {
canvas.height = viewport.height;
canvas.width = viewport.width;
const context = canvas.getContext('2d');
- await page.render({ canvasContext: context, viewport: viewport }).promise;
+ await page.render({ canvasContext: context, viewport: viewport, canvas }).promise;
const blob = await new Promise((resolve) =>
canvas.toBlob(resolve, 'image/png')
);
diff --git a/src/js/logic/pdf-to-tiff.ts b/src/js/logic/pdf-to-tiff.ts
index b630a78..09ce0fa 100644
--- a/src/js/logic/pdf-to-tiff.ts
+++ b/src/js/logic/pdf-to-tiff.ts
@@ -1,14 +1,16 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
+import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import JSZip from 'jszip';
import UTIF from 'utif';
import * as pdfjsLib from 'pdfjs-dist';
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
export async function pdfToTiff() {
showLoader('Converting PDF to TIFF...');
try {
- const pdf = await pdfjsLib.getDocument(
+ const pdf = await getPDFDocument(
await readFileAsArrayBuffer(state.files[0])
).promise;
const zip = new JSZip();
diff --git a/src/js/logic/pdf-to-webp.ts b/src/js/logic/pdf-to-webp.ts
index 4348fff..fe47b0a 100644
--- a/src/js/logic/pdf-to-webp.ts
+++ b/src/js/logic/pdf-to-webp.ts
@@ -1,13 +1,16 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer } from '../utils/helpers.js';
+import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import JSZip from 'jszip';
+import * as pdfjsLib from 'pdfjs-dist';
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
export async function pdfToWebp() {
showLoader('Converting to WebP...');
try {
- // @ts-expect-error TS(2304) FIXME: Cannot find name 'pdfjsLib'.
- const pdf = await pdfjsLib.getDocument(
+ const pdf = await getPDFDocument(
await readFileAsArrayBuffer(state.files[0])
).promise;
const zip = new JSZip();
@@ -18,10 +21,10 @@ export async function pdfToWebp() {
canvas.height = viewport.height;
canvas.width = viewport.width;
const context = canvas.getContext('2d');
- await page.render({ canvasContext: context, viewport: viewport }).promise;
+ await page.render({ canvasContext: context, viewport: viewport, canvas }).promise;
const qualityInput = document.getElementById('webp-quality') as HTMLInputElement;
const quality = qualityInput ? parseFloat(qualityInput.value) : 0.9;
-
+
const blob = await new Promise((resolve) =>
canvas.toBlob(resolve, 'image/webp', quality)
);
diff --git a/src/js/logic/posterize.ts b/src/js/logic/posterize.ts
index d0e14f3..21a395e 100644
--- a/src/js/logic/posterize.ts
+++ b/src/js/logic/posterize.ts
@@ -1,10 +1,12 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, parsePageRanges } from '../utils/helpers.js';
+import { downloadFile, parsePageRanges, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import { PDFDocument, PageSizes } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { createIcons, icons } from 'lucide';
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
const posterizeState = {
pdfJsDoc: null,
pageSnapshots: {},
@@ -121,7 +123,7 @@ export async function setupPosterizeTool() {
.getPageCount()
.toString();
const pdfBytes = await state.pdfDoc.save();
- posterizeState.pdfJsDoc = await pdfjsLib.getDocument({ data: pdfBytes })
+ posterizeState.pdfJsDoc = await getPDFDocument({ data: pdfBytes })
.promise;
posterizeState.pageSnapshots = {};
posterizeState.currentPage = 1;
diff --git a/src/js/logic/remove-blank-pages.ts b/src/js/logic/remove-blank-pages.ts
index f6d4ab5..46313cc 100644
--- a/src/js/logic/remove-blank-pages.ts
+++ b/src/js/logic/remove-blank-pages.ts
@@ -1,10 +1,12 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile } from '../utils/helpers.js';
+import { downloadFile, getPDFDocument } from '../utils/helpers.js';
import { state } from '../state.js';
import { PDFDocument } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist';
import { PDFPageProxy } from 'pdfjs-dist/types/src/display/api.js';
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
let analysisCache = [];
async function isPageBlank(page: PDFPageProxy, threshold: number) {
@@ -38,7 +40,7 @@ async function analyzePages() {
showLoader('Analyzing for blank pages...');
const pdfBytes = await state.pdfDoc.save();
- const pdf = await pdfjsLib.getDocument({ data: pdfBytes }).promise;
+ const pdf = await getPDFDocument({ data: pdfBytes }).promise;
analysisCache = [];
const promises = [];
diff --git a/src/js/logic/rotate.ts b/src/js/logic/rotate.ts
index ba9c10e..623bf10 100644
--- a/src/js/logic/rotate.ts
+++ b/src/js/logic/rotate.ts
@@ -1,7 +1,7 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile } from '../utils/helpers.js';
+import { downloadFile, resetAndReloadTool } from '../utils/helpers.js';
import { state } from '../state.js';
-import { getRotationState } from '../handlers/fileHandler.js';
+import { getRotationState, resetRotationState } from '../handlers/fileHandler.js';
import { degrees } from 'pdf-lib';
@@ -24,6 +24,10 @@ export async function rotate() {
new Blob([rotatedPdfBytes], { type: 'application/pdf' }),
'rotated.pdf'
);
+
+ resetAndReloadTool(() => {
+ resetRotationState();
+ });
} catch (e) {
console.error(e);
showAlert('Error', 'Could not apply rotations.');
diff --git a/src/js/logic/sign-pdf.ts b/src/js/logic/sign-pdf.ts
index caff580..a0f2d4a 100644
--- a/src/js/logic/sign-pdf.ts
+++ b/src/js/logic/sign-pdf.ts
@@ -1,3 +1,4 @@
+import { PDFDocument } from 'pdf-lib';
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { readFileAsArrayBuffer } from '../utils/helpers.js';
import { state } from '../state.js';
@@ -50,7 +51,7 @@ export async function setupSignTool() {
enablePermissions: false,
};
localStorage.setItem('pdfjs.preferences', JSON.stringify(newPrefs));
- } catch {}
+ } catch { }
const viewerUrl = new URL('/pdfjs-viewer/viewer.html', window.location.origin);
const query = new URLSearchParams({ file: blobUrl });
@@ -83,7 +84,7 @@ export async function setupSignTool() {
try {
const highlightBtn = doc.getElementById('editorHighlightButton') as HTMLButtonElement | null;
highlightBtn?.click();
- } catch {}
+ } catch { }
});
}
} catch (e) {
@@ -115,11 +116,41 @@ export async function applyAndSaveSignatures() {
return;
}
- // Delegate to the built-in download behavior of the base viewer.
const app = viewerWindow.PDFViewerApplication;
- app.eventBus?.dispatch('download', { source: app });
+ const flattenCheckbox = document.getElementById('flatten-signature-toggle') as HTMLInputElement | null;
+ const shouldFlatten = flattenCheckbox?.checked;
+
+ if (shouldFlatten) {
+ showLoader('Flattening and saving PDF...');
+
+ const rawPdfBytes = await app.pdfDocument.saveDocument(app.pdfDocument.annotationStorage);
+
+ const pdfBytes = new Uint8Array(rawPdfBytes);
+
+ const pdfDoc = await PDFDocument.load(pdfBytes);
+
+ pdfDoc.getForm().flatten();
+
+ const flattenedPdfBytes = await pdfDoc.save();
+
+ const blob = new Blob([flattenedPdfBytes as BlobPart], { type: 'application/pdf' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `signed_flattened_${state.files[0].name}`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+
+ hideLoader();
+ } else {
+ // Delegate to the built-in download behavior of the base viewer.
+ app.eventBus?.dispatch('download', { source: app });
+ }
} catch (error) {
- console.error('Failed to trigger download in base PDF.js viewer:', error);
+ console.error('Failed to export the signed PDF:', error);
+ hideLoader();
showAlert('Export failed', 'Could not export the signed PDF. Please try again.');
}
}
diff --git a/src/js/logic/split.ts b/src/js/logic/split.ts
index 4ab3e44..45796a0 100644
--- a/src/js/logic/split.ts
+++ b/src/js/logic/split.ts
@@ -1,7 +1,9 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
-import { downloadFile } from '../utils/helpers.js';
+import { downloadFile, getPDFDocument } from '../utils/helpers.js';
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
import { state } from '../state.js';
import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js';
import JSZip from 'jszip';
@@ -27,7 +29,7 @@ async function renderVisualSelector() {
try {
const pdfData = await state.pdfDoc.save();
- const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise;
+ const pdf = await getPDFDocument({ data: pdfData }).promise;
// Function to create wrapper element for each page
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
diff --git a/src/js/main.ts b/src/js/main.ts
index aaeca05..502b49b 100644
--- a/src/js/main.ts
+++ b/src/js/main.ts
@@ -10,10 +10,7 @@ import { formatShortcutDisplay, formatStars } from './utils/helpers.js';
import { APP_VERSION, injectVersion } from '../version.js';
const init = () => {
- pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
- 'pdfjs-dist/build/pdf.worker.min.mjs',
- import.meta.url
- ).toString();
+ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
// Handle simple mode - hide branding sections but keep logo and copyright
// Handle simple mode - hide branding sections but keep logo and copyright
@@ -308,9 +305,87 @@ const init = () => {
});
}
+
// Initialize Shortcuts System
ShortcutsManager.init();
+ // Tab switching for settings modal
+ const shortcutsTabBtn = document.getElementById('shortcuts-tab-btn');
+ const preferencesTabBtn = document.getElementById('preferences-tab-btn');
+ const shortcutsTabContent = document.getElementById('shortcuts-tab-content');
+ const preferencesTabContent = document.getElementById('preferences-tab-content');
+ const shortcutsTabFooter = document.getElementById('shortcuts-tab-footer');
+ const preferencesTabFooter = document.getElementById('preferences-tab-footer');
+ const resetShortcutsBtn = document.getElementById('reset-shortcuts-btn');
+
+ if (shortcutsTabBtn && preferencesTabBtn) {
+ shortcutsTabBtn.addEventListener('click', () => {
+ shortcutsTabBtn.classList.add('bg-indigo-600', 'text-white');
+ shortcutsTabBtn.classList.remove('text-gray-300');
+ preferencesTabBtn.classList.remove('bg-indigo-600', 'text-white');
+ preferencesTabBtn.classList.add('text-gray-300');
+ shortcutsTabContent?.classList.remove('hidden');
+ preferencesTabContent?.classList.add('hidden');
+ shortcutsTabFooter?.classList.remove('hidden');
+ preferencesTabFooter?.classList.add('hidden');
+ resetShortcutsBtn?.classList.remove('hidden');
+ });
+
+ preferencesTabBtn.addEventListener('click', () => {
+ preferencesTabBtn.classList.add('bg-indigo-600', 'text-white');
+ preferencesTabBtn.classList.remove('text-gray-300');
+ shortcutsTabBtn.classList.remove('bg-indigo-600', 'text-white');
+ shortcutsTabBtn.classList.add('text-gray-300');
+ preferencesTabContent?.classList.remove('hidden');
+ shortcutsTabContent?.classList.add('hidden');
+ preferencesTabFooter?.classList.remove('hidden');
+ shortcutsTabFooter?.classList.add('hidden');
+ resetShortcutsBtn?.classList.add('hidden');
+ });
+ }
+
+ // Full-width toggle functionality
+ const fullWidthToggle = document.getElementById('full-width-toggle') as HTMLInputElement;
+ const toolInterface = document.getElementById('tool-interface');
+
+ // Load saved preference
+ const savedFullWidth = localStorage.getItem('fullWidthMode') === 'true';
+ if (fullWidthToggle) {
+ fullWidthToggle.checked = savedFullWidth;
+ applyFullWidthMode(savedFullWidth);
+ }
+
+ function applyFullWidthMode(enabled: boolean) {
+ if (toolInterface) {
+ if (enabled) {
+ toolInterface.classList.remove('max-w-4xl');
+ } else {
+ toolInterface.classList.add('max-w-4xl');
+ }
+ }
+
+ // Apply to all page uploaders
+ const pageUploaders = document.querySelectorAll('#tool-uploader');
+ pageUploaders.forEach((uploader) => {
+ if (enabled) {
+ uploader.classList.remove('max-w-2xl', 'max-w-5xl');
+ } else {
+ // Restore original max-width (most are max-w-2xl, add-stamps is max-w-5xl)
+ if (!uploader.classList.contains('max-w-2xl') && !uploader.classList.contains('max-w-5xl')) {
+ uploader.classList.add('max-w-2xl');
+ }
+ }
+ });
+ }
+
+ if (fullWidthToggle) {
+ fullWidthToggle.addEventListener('change', (e) => {
+ const enabled = (e.target as HTMLInputElement).checked;
+ localStorage.setItem('fullWidthMode', enabled.toString());
+ applyFullWidthMode(enabled);
+ });
+ }
+
// Shortcuts UI Handlers
if (dom.openShortcutsBtn) {
dom.openShortcutsBtn.addEventListener('click', () => {
@@ -705,6 +780,31 @@ const init = () => {
createIcons({ icons });
}
+
+ const scrollToTopBtn = document.getElementById('scroll-to-top-btn');
+
+ if (scrollToTopBtn) {
+ let lastScrollY = window.scrollY;
+
+ window.addEventListener('scroll', () => {
+ const currentScrollY = window.scrollY;
+
+ if (currentScrollY < lastScrollY && currentScrollY > 300) {
+ scrollToTopBtn.classList.add('visible');
+ } else {
+ scrollToTopBtn.classList.remove('visible');
+ }
+
+ lastScrollY = currentScrollY;
+ });
+
+ scrollToTopBtn.addEventListener('click', () => {
+ window.scrollTo({
+ top: 0,
+ behavior: 'instant'
+ });
+ });
+ }
};
document.addEventListener('DOMContentLoaded', init);
diff --git a/src/js/ui.ts b/src/js/ui.ts
index 4781e3e..47291ec 100644
--- a/src/js/ui.ts
+++ b/src/js/ui.ts
@@ -1,10 +1,14 @@
import { resetState } from './state.js';
-import { formatBytes } from './utils/helpers.js';
+import { formatBytes, getPDFDocument } from './utils/helpers.js';
import { tesseractLanguages } from './config/tesseract-languages.js';
import { renderPagesProgressively, cleanupLazyRendering } from './utils/render-utils.js';
import { icons, createIcons } from 'lucide';
import Sortable from 'sortablejs';
-import { getRotationState } from './handlers/fileHandler.js';
+import { getRotationState, updateRotationState } from './handlers/fileHandler.js';
+import * as pdfjsLib from 'pdfjs-dist';
+
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
// Centralizing DOM element selection
export const dom = {
@@ -133,8 +137,7 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
showLoader('Rendering page previews...');
const pdfData = await pdfDoc.save();
- // @ts-expect-error TS(2304) FIXME: Cannot find name 'pdfjsLib'.
- const pdf = await pdfjsLib.getDocument({ data: pdfData }).promise;
+ const pdf = await getPDFDocument({ data: pdfData }).promise;
// Function to create wrapper element for each page
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
@@ -217,10 +220,13 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
'.page-rotator-item'
) as HTMLElement;
const imgEl = card.querySelector('img');
+ const pageIndex = pageNumber - 1;
let currentRotation = parseInt(card.dataset.rotation);
currentRotation = (currentRotation + 90) % 360;
card.dataset.rotation = currentRotation.toString();
imgEl.style.transform = `rotate(${currentRotation}deg)`;
+
+ updateRotationState(pageIndex, currentRotation);
});
controlsDiv.append(pageNumSpan, rotateBtn);
@@ -630,8 +636,8 @@ export const toolTemplates = {
@@ -1314,15 +1320,27 @@ export const toolTemplates = {
-
-
Display Units:
-
- Points (pt)
- Inches (in)
- Millimeters (mm)
- Pixels (at 96 DPI)
-
+
+
+
+
+
+
+ Display Units:
+
+ Points (pt)
+ Inches (in)
+ Millimeters (mm)
+ Pixels (at 96 DPI)
+
+
+
+
+ Export to CSV
+
+
+
@@ -1331,6 +1349,9 @@ export const toolTemplates = {
Dimensions (W x H)
Standard Size
Orientation
+ Aspect Ratio
+ Area
+ Rotation
@@ -1340,6 +1361,7 @@ export const toolTemplates = {
`,
+
'n-up': () => `
N-Up Page Arrangement
Combine multiple pages from your PDF onto a single sheet. This is great for creating booklets or proof sheets.
@@ -1418,11 +1440,19 @@ export const toolTemplates = {
'combine-single-page': () => `
Combine to a Single Page
- Stitch all pages of your PDF together vertically to create one continuous, scrollable page.
+ Stitch all pages of your PDF together vertically or horizontally to create one continuous page.
${createFileInputHTML()}
+
+ Orientation
+
+ Vertical (Stack pages top to bottom)
+ Horizontal (Stack pages left to right)
+
+
+
+
Draw a separator line between pages
+
+
+
Combine Pages
`,
@@ -1747,6 +1790,14 @@ export const toolTemplates = {
+
+
+
+
+ Flatten PDF (use the Save button below)
+
+
+
Save & Download Signed PDF
`,
diff --git a/src/js/utils/full-width.ts b/src/js/utils/full-width.ts
new file mode 100644
index 0000000..ae986e5
--- /dev/null
+++ b/src/js/utils/full-width.ts
@@ -0,0 +1,34 @@
+// Full-width mode utility
+// This script applies the full-width preference from localStorage to page uploaders
+
+export function initFullWidthMode() {
+ const savedFullWidth = localStorage.getItem('fullWidthMode') === 'true';
+
+ if (savedFullWidth) {
+ applyFullWidthMode(true);
+ }
+}
+
+export function applyFullWidthMode(enabled: boolean) {
+ // Apply to all page uploaders
+ const pageUploaders = document.querySelectorAll('#tool-uploader');
+ pageUploaders.forEach((uploader) => {
+ if (enabled) {
+ uploader.classList.remove('max-w-2xl', 'max-w-5xl');
+ } else {
+ // Restore original max-width if not already present
+ if (!uploader.classList.contains('max-w-2xl') && !uploader.classList.contains('max-w-5xl')) {
+ uploader.classList.add('max-w-2xl');
+ }
+ }
+ });
+}
+
+// Auto-initialize on DOM load
+if (typeof document !== 'undefined') {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initFullWidthMode);
+ } else {
+ initFullWidthMode();
+ }
+}
diff --git a/src/js/utils/helpers.ts b/src/js/utils/helpers.ts
index 4774801..833a447 100644
--- a/src/js/utils/helpers.ts
+++ b/src/js/utils/helpers.ts
@@ -1,6 +1,9 @@
import createModule from '@neslinesli93/qpdf-wasm';
-import { showLoader, hideLoader, showAlert } from '../ui';
+import { showLoader, hideLoader, showAlert } from '../ui.js';
import { createIcons } from 'lucide';
+import { state, resetState } from '../state.js';
+import * as pdfjsLib from 'pdfjs-dist'
+
const STANDARD_SIZES = {
A4: { width: 595.28, height: 841.89 },
@@ -45,16 +48,17 @@ export function convertPoints(points: any, unit: any) {
return result.toFixed(2);
}
-export const hexToRgb = (hex: any) => {
- const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+// 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)
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 }; // Default to black
-};
+ : { r: 0, g: 0, b: 0 }
+}
export const formatBytes = (bytes: any, decimals = 1) => {
if (bytes === 0) return '0 Bytes';
@@ -195,14 +199,86 @@ export function formatStars(num: number) {
return num.toLocaleString();
};
+/**
+ * Truncates a filename to a maximum length, adding ellipsis if needed.
+ * Preserves the file extension.
+ * @param filename - The filename to truncate
+ * @param maxLength - Maximum length (default: 30)
+ * @returns Truncated filename with ellipsis if needed
+ */
+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 availableLength = maxLength - extension.length - 3; // 3 for '...'
+
+ if (availableLength <= 0) {
+ return filename.substring(0, maxLength - 3) + '...';
+ }
+
+ return nameWithoutExt.substring(0, availableLength) + '...' + extension;
+}
+
export function formatShortcutDisplay(shortcut: string, isMac: boolean): string {
- if (!shortcut) return '';
- return shortcut
- .replace('mod', isMac ? '⌘' : 'Ctrl')
- .replace('ctrl', isMac ? '^' : 'Ctrl') // Control key on Mac shows as ^
- .replace('alt', isMac ? '⌥' : 'Alt')
- .replace('shift', 'Shift')
- .split('+')
- .map(k => k.charAt(0).toUpperCase() + k.slice(1))
- .join(isMac ? '' : '+');
-}
\ No newline at end of file
+ if (!shortcut) return '';
+ return shortcut
+ .replace('mod', isMac ? '⌘' : 'Ctrl')
+ .replace('ctrl', isMac ? '^' : 'Ctrl') // Control key on Mac shows as ^
+ .replace('alt', isMac ? '⌥' : 'Alt')
+ .replace('shift', 'Shift')
+ .split('+')
+ .map(k => k.charAt(0).toUpperCase() + k.slice(1))
+ .join(isMac ? '' : '+');
+}
+
+export function resetAndReloadTool(preResetCallback?: () => void) {
+ const toolid = state.activeTool;
+
+ if (preResetCallback) {
+ preResetCallback();
+ }
+
+ resetState();
+
+ if (toolid) {
+ const element = document.querySelector(
+ `[data-tool-id="${toolid}"]`
+ ) as HTMLElement;
+ if (element) element.click();
+ }
+}
+
+/**
+ * Wrapper for pdfjsLib.getDocument that adds the required wasmUrl configuration.
+ * Use this instead of calling pdfjsLib.getDocument directly.
+ * @param src The source to load (url string, typed array, or parameters object)
+ * @returns The PDF loading task
+ */
+export function getPDFDocument(src: any) {
+ let params = src;
+
+ // 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 };
+ } else if (src instanceof Uint8Array || src instanceof ArrayBuffer) {
+ params = { data: src };
+ }
+
+ // Ensure params is an object
+ if (typeof params !== 'object' || params === null) {
+ params = {};
+ }
+
+ // Add wasmUrl pointing to our public/wasm directory
+ // This is required for PDF.js v5+ to load OpenJPEG for certain images
+ return pdfjsLib.getDocument({
+ ...params,
+ wasmUrl: '/pdfjs-viewer/wasm/',
+ });
+}
diff --git a/src/js/utils/render-utils.ts b/src/js/utils/render-utils.ts
index 0e14119..526c8c5 100644
--- a/src/js/utils/render-utils.ts
+++ b/src/js/utils/render-utils.ts
@@ -1,5 +1,7 @@
import * as pdfjsLib from 'pdfjs-dist';
+pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+
/**
* Configuration for progressive rendering
*/
diff --git a/src/pages/add-stamps.html b/src/pages/add-stamps.html
index 967100d..a63258e 100644
--- a/src/pages/add-stamps.html
+++ b/src/pages/add-stamps.html
@@ -59,7 +59,8 @@
-
+
@@ -185,6 +186,7 @@
+
diff --git a/src/pages/bookmark.html b/src/pages/bookmark.html
index 7e330f4..930b661 100644
--- a/src/pages/bookmark.html
+++ b/src/pages/bookmark.html
@@ -61,14 +61,16 @@
-
+
Back to Tools
+
Edit Bookmarks
- Upload a PDF to begin editing bookmarks
+ Add, edit, import, delete and extract PDF bookmarks.
@@ -489,6 +491,7 @@
+
diff --git a/src/pages/form-creator.html b/src/pages/form-creator.html
new file mode 100644
index 0000000..8a5088d
--- /dev/null
+++ b/src/pages/form-creator.html
@@ -0,0 +1,404 @@
+
+
+
+
+
+
+
Create PDF Form - BentoPDF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to Tools
+
+
+
Create PDF Form
+
+ Upload an existing PDF or create a blank PDF to start adding form fields.
+
+
+
+
+
+
+ Click to select a PDF or drag and drop
+
+
Single PDF file
+
Your files never leave your device.
+
+
+
+
+
+
+
+
+
+
+
+
+ Create Blank PDF
+
+
+
+
Page Size:
+
+ Letter (8.5" × 11")
+ A4 (210mm × 297mm)
+ A5 (148mm × 210mm)
+ Legal (8.5" × 14")
+ Tabloid (11" × 17")
+ A3 (297mm × 420mm)
+ Custom
+
+
+
+
+
+ Start Creating
+
+
+
+
+
+
+
+
+
+ Back to Tools
+
+
+
Create PDF Form
+
+ Drag and drop fields onto the canvas to create a fillable PDF form. Customize field properties and
+ download your form.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Page 1 of 1
+
+
+
+
+
+
+
+ Reset
+
+
+
+ Add Page
+
+
+
+
+
+
+
+
+ Fields:
+
+
+
+
+ Text
+
+
+
+
+ Checkbox
+
+
+
+
+ Radio
+
+
+
+
+ Dropdown
+
+
+
+
+ List
+
+
+
+
+ Button
+
+
+
+
+ Signature
+
+
+
+
+ Date
+
+
+
+
+ Image
+
+
+
+
+
+
+
+ Download PDF Form
+
+
+
+
+
+
+
+
+
+
+
+
+
BentoPDF
+
+
+ © 2025 BentoPDF. All rights reserved.
+
+
+ Version
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Error
+
An error occurred.
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/json-to-pdf.html b/src/pages/json-to-pdf.html
index 6f8fd90..da4d39e 100644
--- a/src/pages/json-to-pdf.html
+++ b/src/pages/json-to-pdf.html
@@ -59,7 +59,8 @@