- The selected format will be enforced when the user types or picks a date.
+
- `
- } else if (field.type === 'image') {
- specificProps = `
+ `;
+ } else if (field.type === 'image') {
+ specificProps = `
Label / Prompt
@@ -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
+ // 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
- }
-
- const isDuplicateInFields = fields.some(f => f.id !== field.id && f.name === newName)
- const isDuplicateInPdf = existingFieldNames.has(newName)
-
- 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
- }
-
- nameError.classList.add('hidden')
- propName.classList.remove('border-red-500')
- return true
+ 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;
}
- propName.addEventListener('input', (e) => {
- const newName = (e.target as HTMLInputElement).value.trim()
- validateName(newName)
- })
-
- propName.addEventListener('change', (e) => {
- const newName = (e.target as HTMLInputElement).value.trim()
-
- if (!validateName(newName)) {
- (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
- })
-
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
- }
- }
- })
- }
+ nameError.classList.add('hidden');
+ propName.classList.remove('border-red-500');
+ return true;
}
- propRequired.addEventListener('change', (e) => {
- field.required = (e.target as HTMLInputElement).checked
- })
+ const isDuplicateInFields = fields.some(
+ (f) => f.id !== field.id && f.name === newName
+ );
+ const isDuplicateInPdf = existingFieldNames.has(newName);
- 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')
- }
-
- // 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)
- })
+ 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;
}
+
+ nameError.classList.add('hidden');
+ propName.classList.remove('border-red-500');
+ return true;
+ };
+
+ propName.addEventListener('input', (e) => {
+ const newName = (e.target as HTMLInputElement).value.trim();
+ validateName(newName);
+ });
+
+ propName.addEventListener('change', (e) => {
+ const newName = (e.target as HTMLInputElement).value.trim();
+
+ if (!validateName(newName)) {
+ (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;
+ });
+
+ 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');
+ }
+
+ // 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;
+ 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;
+ }
+ updateExample();
+ const fieldWrapper = document.getElementById(field.id);
+ if (fieldWrapper) {
+ const textSpan = fieldWrapper.querySelector(
+ '.date-format-text'
+ ) as HTMLElement;
+ if (textSpan) {
+ textSpan.textContent = field.dateFormat;
+ }
+ }
+ 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[] = []
+ // 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)
+ 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 (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')
+ 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 (!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 (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 (uploadedPdfDoc) {
+ pdfDoc = uploadedPdfDoc;
+ } else {
+ pdfDoc = await PDFDocument.create();
+
+ for (const pageData of pages) {
+ pdfDoc.addPage([pageData.width, pageData.height]);
+ }
}
- 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
- }
+ const form = pdfDoc.getForm();
- if (fields.length === 0) {
- alert('Please add at least one field before downloading.')
- return
- }
+ const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
- if (pages.length === 0) {
- alert('No pages found. Please create a blank PDF or upload one.')
- return
- }
+ // Set document metadata for accessibility
+ pdfDoc.setTitle('Fillable Form');
+ pdfDoc.setAuthor('BentoPDF');
+ pdfDoc.setLanguage('en-US');
- try {
- let pdfDoc: PDFDocument
+ const radioGroups = new Map(); // Track created radio groups
- if (uploadedPdfDoc) {
- pdfDoc = uploadedPdfDoc
+ 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))
- })
- }
-
- } 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 (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 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))
- }
- }
+ // Handle combing
+ if (field.combCells > 0) {
+ textField.setMaxLength(field.combCells);
+ textField.enableCombing();
+ } else if (field.maxLength > 0) {
+ textField.setMaxLength(field.maxLength);
}
- 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')
- }
+ // Disable multiline to prevent RTL issues (unless explicitly enabled)
+ if (!field.multiline) {
+ textField.disableMultiline();
} else {
- showModal('Error', 'Error generating PDF: ' + errorMessage, 'error')
+ 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));
+ });
+ }
+ } 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 (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 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 viewerWindow = iframe.contentWindow as any;
+ if (viewerWindow && viewerWindow.PDFViewerApplication) {
+ const app = viewerWindow.PDFViewerApplication;
- 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 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)
+ `;
+ viewerWindow.document.head.appendChild(style);
- const checkRender = setInterval(() => {
- if (app.pdfViewer && app.pdfViewer.pagesCount > 0) {
- clearInterval(checkRender)
+ 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 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)`
+ 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)
+ 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
+ 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
- }
+ pdfViewerOffset = {
+ x: rect.left + borderLeft,
+ y: rect.top + borderTop,
+ };
- const contentWidth = rect.width - borderLeft - borderRight
- pdfViewerScale = contentWidth / currentPage.width
+ 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)
+ 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);
}
- }
+ };
- fields.filter(f => f.pageIndex === currentPageIndex).forEach(field => {
- renderField(field)
- })
+ 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);
+ });
}
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);
+
+ // Check for existing fields and update counter
+ existingFieldNames.clear();
try {
- const arrayBuffer = await file.arrayBuffer()
- uploadedPdfDoc = await PDFDocument.load(arrayBuffer)
+ 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)
- }
-
- // 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)
+ if (field instanceof PDFRadioGroup) {
+ existingRadioGroups.add(name);
}
- uploadedPdfjsDoc = await getPDFDocument({ data: arrayBuffer }).promise
+ // console.log(' Field:', name, '| Type:', field.constructor.name)
- const pageCount = uploadedPdfDoc.getPageCount()
- pages = []
-
- for (let i = 0; i < pageCount; i++) {
- const page = uploadedPdfDoc.getPage(i)
- const { width, height } = page.getSize()
-
- pages.push({
- index: i,
- width,
- height,
- pdfPageData: undefined
- })
+ 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);
+ }
}
+ });
- 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')
+ // 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);
}
+
+ uploadedPdfjsDoc = 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();
+
+ pages.push({
+ index: i,
+ width,
+ height,
+ pdfPageData: undefined,
+ });
+ }
+
+ 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)
- }
-})
+ 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')
+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
+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
+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
+ errorModalTitle.textContent = title;
+ errorModalMessage.textContent = message;
+ errorModalClose.textContent = buttonText;
- modalCloseCallback = onClose || null
- errorModal.classList.remove('hidden')
+ 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/image-to-pdf-page.ts b/src/js/logic/image-to-pdf-page.ts
index 3051aca..d523edf 100644
--- a/src/js/logic/image-to-pdf-page.ts
+++ b/src/js/logic/image-to-pdf-page.ts
@@ -1,275 +1,293 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
import heic2any from 'heic2any';
-const SUPPORTED_FORMATS = '.jpg,.jpeg,.png,.bmp,.gif,.tiff,.tif,.pnm,.pgm,.pbm,.ppm,.pam,.jxr,.jpx,.jp2,.psd,.svg,.heic,.heif,.webp';
-const SUPPORTED_FORMATS_DISPLAY = 'JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP';
+const SUPPORTED_FORMATS =
+ '.jpg,.jpeg,.png,.bmp,.gif,.tiff,.tif,.pnm,.pgm,.pbm,.ppm,.pam,.jxr,.jpx,.jp2,.psd,.svg,.heic,.heif,.webp';
+const SUPPORTED_FORMATS_DISPLAY =
+ 'JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP';
let files: File[] = [];
-let pymupdf: PyMuPDF | null = null;
+let pymupdf: any = null;
if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', initializePage);
+ document.addEventListener('DOMContentLoaded', initializePage);
} else {
- initializePage();
+ initializePage();
}
function initializePage() {
- createIcons({ icons });
+ createIcons({ icons });
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const processBtn = document.getElementById('process-btn');
- const formatDisplay = document.getElementById('supported-formats');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const processBtn = document.getElementById('process-btn');
+ const formatDisplay = document.getElementById('supported-formats');
- if (formatDisplay) {
- formatDisplay.textContent = SUPPORTED_FORMATS_DISPLAY;
- }
+ if (formatDisplay) {
+ formatDisplay.textContent = SUPPORTED_FORMATS_DISPLAY;
+ }
- if (fileInput) {
- fileInput.accept = SUPPORTED_FORMATS;
- fileInput.addEventListener('change', handleFileUpload);
- }
+ if (fileInput) {
+ fileInput.accept = SUPPORTED_FORMATS;
+ fileInput.addEventListener('change', handleFileUpload);
+ }
- if (dropZone) {
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
-
- dropZone.addEventListener('dragleave', () => {
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- const droppedFiles = e.dataTransfer?.files;
- if (droppedFiles && droppedFiles.length > 0) {
- handleFiles(droppedFiles);
- }
- });
-
- fileInput?.addEventListener('click', () => {
- if (fileInput) fileInput.value = '';
- });
- }
-
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput?.click();
- });
- }
-
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', () => {
- files = [];
- updateUI();
- });
- }
-
- if (processBtn) {
- processBtn.addEventListener('click', convertToPdf);
- }
-
- document.getElementById('back-to-tools')?.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
+ if (dropZone) {
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
});
+
+ dropZone.addEventListener('dragleave', () => {
+ dropZone.classList.remove('bg-gray-700');
+ });
+
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ const droppedFiles = e.dataTransfer?.files;
+ if (droppedFiles && droppedFiles.length > 0) {
+ handleFiles(droppedFiles);
+ }
+ });
+
+ fileInput?.addEventListener('click', () => {
+ if (fileInput) fileInput.value = '';
+ });
+ }
+
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput?.click();
+ });
+ }
+
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', () => {
+ files = [];
+ updateUI();
+ });
+ }
+
+ if (processBtn) {
+ processBtn.addEventListener('click', convertToPdf);
+ }
+
+ document.getElementById('back-to-tools')?.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
}
function handleFileUpload(e: Event) {
- const input = e.target as HTMLInputElement;
- if (input.files && input.files.length > 0) {
- handleFiles(input.files);
- }
+ const input = e.target as HTMLInputElement;
+ if (input.files && input.files.length > 0) {
+ handleFiles(input.files);
+ }
}
function getFileExtension(filename: string): string {
- return '.' + filename.split('.').pop()?.toLowerCase() || '';
+ return '.' + filename.split('.').pop()?.toLowerCase() || '';
}
function isValidImageFile(file: File): boolean {
- const ext = getFileExtension(file.name);
- const validExtensions = SUPPORTED_FORMATS.split(',');
- return validExtensions.includes(ext) || file.type.startsWith('image/');
+ const ext = getFileExtension(file.name);
+ const validExtensions = SUPPORTED_FORMATS.split(',');
+ return validExtensions.includes(ext) || file.type.startsWith('image/');
}
function handleFiles(newFiles: FileList) {
- const validFiles = Array.from(newFiles).filter(isValidImageFile);
+ const validFiles = Array.from(newFiles).filter(isValidImageFile);
- if (validFiles.length < newFiles.length) {
- showAlert('Invalid Files', 'Some files were skipped. Only supported image formats are allowed.');
- }
+ if (validFiles.length < newFiles.length) {
+ showAlert(
+ 'Invalid Files',
+ 'Some files were skipped. Only supported image formats are allowed.'
+ );
+ }
- if (validFiles.length > 0) {
- files = [...files, ...validFiles];
- updateUI();
- }
+ if (validFiles.length > 0) {
+ files = [...files, ...validFiles];
+ updateUI();
+ }
}
const resetState = () => {
- files = [];
- updateUI();
+ files = [];
+ updateUI();
};
function updateUI() {
- const fileDisplayArea = document.getElementById('file-display-area');
- const fileControls = document.getElementById('file-controls');
- const optionsDiv = document.getElementById('jpg-to-pdf-options');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const fileControls = document.getElementById('file-controls');
+ const optionsDiv = document.getElementById('jpg-to-pdf-options');
- if (!fileDisplayArea || !fileControls || !optionsDiv) return;
+ if (!fileDisplayArea || !fileControls || !optionsDiv) return;
- fileDisplayArea.innerHTML = '';
+ fileDisplayArea.innerHTML = '';
- if (files.length > 0) {
- fileControls.classList.remove('hidden');
- optionsDiv.classList.remove('hidden');
+ if (files.length > 0) {
+ fileControls.classList.remove('hidden');
+ optionsDiv.classList.remove('hidden');
- files.forEach((file, index) => {
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ files.forEach((file, index) => {
+ 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 items-center gap-2 overflow-hidden';
+ const infoContainer = document.createElement('div');
+ infoContainer.className = 'flex items-center gap-2 overflow-hidden';
- const nameSpan = document.createElement('span');
- nameSpan.className = 'truncate font-medium text-gray-200';
- nameSpan.textContent = file.name;
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'truncate font-medium text-gray-200';
+ nameSpan.textContent = file.name;
- const sizeSpan = document.createElement('span');
- sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
- sizeSpan.textContent = `(${formatBytes(file.size)})`;
+ const sizeSpan = document.createElement('span');
+ sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
+ sizeSpan.textContent = `(${formatBytes(file.size)})`;
- infoContainer.append(nameSpan, sizeSpan);
+ infoContainer.append(nameSpan, sizeSpan);
- const removeBtn = document.createElement('button');
- removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
- removeBtn.innerHTML = ' ';
- removeBtn.onclick = () => {
- files = files.filter((_, i) => i !== index);
- updateUI();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className =
+ 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = ' ';
+ removeBtn.onclick = () => {
+ files = files.filter((_, i) => i !== index);
+ updateUI();
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
- });
- createIcons({ icons });
- } else {
- fileControls.classList.add('hidden');
- optionsDiv.classList.add('hidden');
- }
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ });
+ createIcons({ icons });
+ } else {
+ fileControls.classList.add('hidden');
+ optionsDiv.classList.add('hidden');
+ }
}
-async function ensurePyMuPDF(): Promise {
- if (!pymupdf) {
- pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
- await pymupdf.load();
- }
- return pymupdf;
+async function ensurePyMuPDF(): Promise {
+ if (!pymupdf) {
+ pymupdf = await loadPyMuPDF();
+ }
+ return pymupdf;
}
async function preprocessFile(file: File): Promise {
- const ext = getFileExtension(file.name);
+ const ext = getFileExtension(file.name);
- if (ext === '.heic' || ext === '.heif') {
- try {
- const conversionResult = await heic2any({
- blob: file,
- toType: 'image/png',
- quality: 0.9,
- });
+ if (ext === '.heic' || ext === '.heif') {
+ try {
+ const conversionResult = await heic2any({
+ blob: file,
+ toType: 'image/png',
+ quality: 0.9,
+ });
- const blob = Array.isArray(conversionResult) ? conversionResult[0] : conversionResult;
- return new File([blob], file.name.replace(/\.(heic|heif)$/i, '.png'), { type: 'image/png' });
- } catch (e) {
- console.error(`Failed to convert HEIC: ${file.name}`, e);
- throw new Error(`Failed to process HEIC file: ${file.name}`);
- }
+ const blob = Array.isArray(conversionResult)
+ ? conversionResult[0]
+ : conversionResult;
+ return new File([blob], file.name.replace(/\.(heic|heif)$/i, '.png'), {
+ type: 'image/png',
+ });
+ } catch (e) {
+ console.error(`Failed to convert HEIC: ${file.name}`, e);
+ throw new Error(`Failed to process HEIC file: ${file.name}`);
}
+ }
- if (ext === '.webp') {
- try {
- return await new Promise((resolve, reject) => {
- const img = new Image();
- const url = URL.createObjectURL(file);
+ if (ext === '.webp') {
+ try {
+ return await new Promise((resolve, reject) => {
+ const img = new Image();
+ const url = URL.createObjectURL(file);
- img.onload = () => {
- const canvas = document.createElement('canvas');
- canvas.width = img.width;
- canvas.height = img.height;
- const ctx = canvas.getContext('2d');
- if (!ctx) {
- URL.revokeObjectURL(url);
- reject(new Error('Canvas context failed'));
- return;
- }
- ctx.drawImage(img, 0, 0);
- canvas.toBlob((blob) => {
- URL.revokeObjectURL(url);
- if (blob) {
- resolve(new File([blob], file.name.replace(/\.webp$/i, '.png'), { type: 'image/png' }));
- } else {
- reject(new Error('Canvas toBlob failed'));
- }
- }, 'image/png');
- };
+ img.onload = () => {
+ const canvas = document.createElement('canvas');
+ canvas.width = img.width;
+ canvas.height = img.height;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ URL.revokeObjectURL(url);
+ reject(new Error('Canvas context failed'));
+ return;
+ }
+ ctx.drawImage(img, 0, 0);
+ canvas.toBlob((blob) => {
+ URL.revokeObjectURL(url);
+ if (blob) {
+ resolve(
+ new File([blob], file.name.replace(/\.webp$/i, '.png'), {
+ type: 'image/png',
+ })
+ );
+ } else {
+ reject(new Error('Canvas toBlob failed'));
+ }
+ }, 'image/png');
+ };
- img.onerror = () => {
- URL.revokeObjectURL(url);
- reject(new Error('Failed to load WebP image'));
- };
+ img.onerror = () => {
+ URL.revokeObjectURL(url);
+ reject(new Error('Failed to load WebP image'));
+ };
- img.src = url;
- });
- } catch (e) {
- console.error(`Failed to convert WebP: ${file.name}`, e);
- throw new Error(`Failed to process WebP file: ${file.name}`);
- }
+ img.src = url;
+ });
+ } catch (e) {
+ console.error(`Failed to convert WebP: ${file.name}`, e);
+ throw new Error(`Failed to process WebP file: ${file.name}`);
}
+ }
- return file;
+ return file;
}
async function convertToPdf() {
- if (files.length === 0) {
- showAlert('No Files', 'Please select at least one image file.');
- return;
+ if (files.length === 0) {
+ showAlert('No Files', 'Please select at least one image file.');
+ return;
+ }
+
+ showLoader('Processing images...');
+
+ try {
+ const processedFiles: File[] = [];
+ for (const file of files) {
+ try {
+ const processed = await preprocessFile(file);
+ processedFiles.push(processed);
+ } catch (error: any) {
+ console.warn(error);
+ throw error;
+ }
}
- showLoader('Processing images...');
+ showLoader('Loading engine...');
+ const mupdf = await ensurePyMuPDF();
- try {
- const processedFiles: File[] = [];
- for (const file of files) {
- try {
- const processed = await preprocessFile(file);
- processedFiles.push(processed);
- } catch (error: any) {
- console.warn(error);
- throw error;
- }
- }
+ showLoader('Converting images to PDF...');
+ const pdfBlob = await mupdf.imagesToPdf(processedFiles);
- showLoader('Loading engine...');
- const mupdf = await ensurePyMuPDF();
+ downloadFile(pdfBlob, 'images_to_pdf.pdf');
- showLoader('Converting images to PDF...');
- const pdfBlob = await mupdf.imagesToPdf(processedFiles);
-
- downloadFile(pdfBlob, 'images_to_pdf.pdf');
-
- showAlert('Success', 'PDF created successfully!', 'success', () => {
- resetState();
- });
- } catch (e: any) {
- console.error('[ImageToPDF]', e);
- showAlert('Conversion Error', e.message || 'Failed to convert images to PDF.');
- } finally {
- hideLoader();
- }
+ showAlert('Success', 'PDF created successfully!', 'success', () => {
+ resetState();
+ });
+ } catch (e: any) {
+ console.error('[ImageToPDF]', e);
+ showAlert(
+ 'Conversion Error',
+ e.message || 'Failed to convert images to PDF.'
+ );
+ } finally {
+ hideLoader();
+ }
}
diff --git a/src/js/logic/jpg-to-pdf-page.ts b/src/js/logic/jpg-to-pdf-page.ts
index 40264cd..542a3f1 100644
--- a/src/js/logic/jpg-to-pdf-page.ts
+++ b/src/js/logic/jpg-to-pdf-page.ts
@@ -1,195 +1,205 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
const SUPPORTED_FORMATS = '.jpg,.jpeg,.jp2,.jpx';
const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/jp2'];
let files: File[] = [];
-let pymupdf: PyMuPDF | null = null;
+let pymupdf: any = null;
if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', initializePage);
+ document.addEventListener('DOMContentLoaded', initializePage);
} else {
- initializePage();
+ initializePage();
}
function initializePage() {
- createIcons({ icons });
+ createIcons({ icons });
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const processBtn = document.getElementById('process-btn');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const processBtn = document.getElementById('process-btn');
- if (fileInput) {
- fileInput.addEventListener('change', handleFileUpload);
- }
+ if (fileInput) {
+ fileInput.addEventListener('change', handleFileUpload);
+ }
- if (dropZone) {
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
-
- dropZone.addEventListener('dragleave', () => {
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- const droppedFiles = e.dataTransfer?.files;
- if (droppedFiles && droppedFiles.length > 0) {
- handleFiles(droppedFiles);
- }
- });
-
- fileInput?.addEventListener('click', () => {
- if (fileInput) fileInput.value = '';
- });
- }
-
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput?.click();
- });
- }
-
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', () => {
- files = [];
- updateUI();
- });
- }
-
- if (processBtn) {
- processBtn.addEventListener('click', convertToPdf);
- }
-
- document.getElementById('back-to-tools')?.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
+ if (dropZone) {
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
});
+
+ dropZone.addEventListener('dragleave', () => {
+ dropZone.classList.remove('bg-gray-700');
+ });
+
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ const droppedFiles = e.dataTransfer?.files;
+ if (droppedFiles && droppedFiles.length > 0) {
+ handleFiles(droppedFiles);
+ }
+ });
+
+ fileInput?.addEventListener('click', () => {
+ if (fileInput) fileInput.value = '';
+ });
+ }
+
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput?.click();
+ });
+ }
+
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', () => {
+ files = [];
+ updateUI();
+ });
+ }
+
+ if (processBtn) {
+ processBtn.addEventListener('click', convertToPdf);
+ }
+
+ document.getElementById('back-to-tools')?.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
}
function handleFileUpload(e: Event) {
- const input = e.target as HTMLInputElement;
- if (input.files && input.files.length > 0) {
- handleFiles(input.files);
- }
+ const input = e.target as HTMLInputElement;
+ if (input.files && input.files.length > 0) {
+ handleFiles(input.files);
+ }
}
function getFileExtension(filename: string): string {
- return '.' + (filename.split('.').pop()?.toLowerCase() || '');
+ return '.' + (filename.split('.').pop()?.toLowerCase() || '');
}
function isValidImageFile(file: File): boolean {
- const ext = getFileExtension(file.name);
- const validExtensions = SUPPORTED_FORMATS.split(',');
- return validExtensions.includes(ext) || SUPPORTED_MIME_TYPES.includes(file.type);
+ const ext = getFileExtension(file.name);
+ const validExtensions = SUPPORTED_FORMATS.split(',');
+ return (
+ validExtensions.includes(ext) || SUPPORTED_MIME_TYPES.includes(file.type)
+ );
}
function handleFiles(newFiles: FileList) {
- const validFiles = Array.from(newFiles).filter(isValidImageFile);
+ const validFiles = Array.from(newFiles).filter(isValidImageFile);
- if (validFiles.length < newFiles.length) {
- showAlert('Invalid Files', 'Some files were skipped. Only JPG, JPEG, JP2, and JPX files are allowed.');
- }
+ if (validFiles.length < newFiles.length) {
+ showAlert(
+ 'Invalid Files',
+ 'Some files were skipped. Only JPG, JPEG, JP2, and JPX files are allowed.'
+ );
+ }
- if (validFiles.length > 0) {
- files = [...files, ...validFiles];
- updateUI();
- }
+ if (validFiles.length > 0) {
+ files = [...files, ...validFiles];
+ updateUI();
+ }
}
const resetState = () => {
- files = [];
- updateUI();
+ files = [];
+ updateUI();
};
function updateUI() {
- const fileDisplayArea = document.getElementById('file-display-area');
- const fileControls = document.getElementById('file-controls');
- const optionsDiv = document.getElementById('jpg-to-pdf-options');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const fileControls = document.getElementById('file-controls');
+ const optionsDiv = document.getElementById('jpg-to-pdf-options');
- if (!fileDisplayArea || !fileControls || !optionsDiv) return;
+ if (!fileDisplayArea || !fileControls || !optionsDiv) return;
- fileDisplayArea.innerHTML = '';
+ fileDisplayArea.innerHTML = '';
- if (files.length > 0) {
- fileControls.classList.remove('hidden');
- optionsDiv.classList.remove('hidden');
+ if (files.length > 0) {
+ fileControls.classList.remove('hidden');
+ optionsDiv.classList.remove('hidden');
- files.forEach((file, index) => {
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ files.forEach((file, index) => {
+ 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 items-center gap-2 overflow-hidden';
+ const infoContainer = document.createElement('div');
+ infoContainer.className = 'flex items-center gap-2 overflow-hidden';
- const nameSpan = document.createElement('span');
- nameSpan.className = 'truncate font-medium text-gray-200';
- nameSpan.textContent = file.name;
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'truncate font-medium text-gray-200';
+ nameSpan.textContent = file.name;
- const sizeSpan = document.createElement('span');
- sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
- sizeSpan.textContent = `(${formatBytes(file.size)})`;
+ const sizeSpan = document.createElement('span');
+ sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
+ sizeSpan.textContent = `(${formatBytes(file.size)})`;
- infoContainer.append(nameSpan, sizeSpan);
+ infoContainer.append(nameSpan, sizeSpan);
- const removeBtn = document.createElement('button');
- removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
- removeBtn.innerHTML = ' ';
- removeBtn.onclick = () => {
- files = files.filter((_, i) => i !== index);
- updateUI();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className =
+ 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = ' ';
+ removeBtn.onclick = () => {
+ files = files.filter((_, i) => i !== index);
+ updateUI();
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
- });
- createIcons({ icons });
- } else {
- fileControls.classList.add('hidden');
- optionsDiv.classList.add('hidden');
- }
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ });
+ createIcons({ icons });
+ } else {
+ fileControls.classList.add('hidden');
+ optionsDiv.classList.add('hidden');
+ }
}
-async function ensurePyMuPDF(): Promise {
- if (!pymupdf) {
- pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
- await pymupdf.load();
- }
- return pymupdf;
+async function ensurePyMuPDF(): Promise {
+ if (!pymupdf) {
+ pymupdf = await loadPyMuPDF();
+ }
+ return pymupdf;
}
async function convertToPdf() {
- if (files.length === 0) {
- showAlert('No Files', 'Please select at least one JPG or JPEG2000 image.');
- return;
- }
+ if (files.length === 0) {
+ showAlert('No Files', 'Please select at least one JPG or JPEG2000 image.');
+ return;
+ }
- showLoader('Loading engine...');
+ showLoader('Loading engine...');
- try {
- const mupdf = await ensurePyMuPDF();
+ try {
+ const mupdf = await ensurePyMuPDF();
- showLoader('Converting images to PDF...');
+ showLoader('Converting images to PDF...');
- const pdfBlob = await mupdf.imagesToPdf(files);
+ const pdfBlob = await mupdf.imagesToPdf(files);
- downloadFile(pdfBlob, 'from_jpgs.pdf');
+ downloadFile(pdfBlob, 'from_jpgs.pdf');
- showAlert('Success', 'PDF created successfully!', 'success', () => {
- resetState();
- });
- } catch (e: any) {
- console.error('[JpgToPdf]', e);
- showAlert('Conversion Error', e.message || 'Failed to convert images to PDF.');
- } finally {
- hideLoader();
- }
+ showAlert('Success', 'PDF created successfully!', 'success', () => {
+ resetState();
+ });
+ } catch (e: any) {
+ console.error('[JpgToPdf]', e);
+ showAlert(
+ 'Conversion Error',
+ e.message || 'Failed to convert images to PDF.'
+ );
+ } finally {
+ hideLoader();
+ }
}
diff --git a/src/js/logic/json-to-pdf.ts b/src/js/logic/json-to-pdf.ts
index e483781..0c9735d 100644
--- a/src/js/logic/json-to-pdf.ts
+++ b/src/js/logic/json-to-pdf.ts
@@ -1,101 +1,133 @@
-import JSZip from 'jszip'
-import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpers';
+import JSZip from 'jszip';
+import {
+ downloadFile,
+ formatBytes,
+ readFileAsArrayBuffer,
+} from '../utils/helpers';
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
+import { isCpdfAvailable } from '../utils/cpdf-helper.js';
+import {
+ showWasmRequiredDialog,
+ WasmProvider,
+} from '../utils/wasm-provider.js';
-const worker = new Worker(import.meta.env.BASE_URL + 'workers/json-to-pdf.worker.js');
+const worker = new Worker(
+ import.meta.env.BASE_URL + 'workers/json-to-pdf.worker.js'
+);
-let selectedFiles: File[] = []
+let selectedFiles: File[] = [];
-const jsonFilesInput = document.getElementById('jsonFiles') as HTMLInputElement
-const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement
-const statusMessage = document.getElementById('status-message') as HTMLDivElement
-const fileListDiv = document.getElementById('fileList') as HTMLDivElement
-const backToToolsBtn = document.getElementById('back-to-tools') as HTMLButtonElement
+const jsonFilesInput = document.getElementById('jsonFiles') as HTMLInputElement;
+const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement;
+const statusMessage = document.getElementById(
+ 'status-message'
+) as HTMLDivElement;
+const fileListDiv = document.getElementById('fileList') as HTMLDivElement;
+const backToToolsBtn = document.getElementById(
+ 'back-to-tools'
+) as HTMLButtonElement;
function showStatus(
message: string,
type: 'success' | 'error' | 'info' = 'info'
) {
- statusMessage.textContent = message
- statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${type === 'success'
- ? 'bg-green-900 text-green-200'
- : type === 'error'
- ? 'bg-red-900 text-red-200'
- : 'bg-blue-900 text-blue-200'
- }`
- statusMessage.classList.remove('hidden')
+ statusMessage.textContent = message;
+ statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${
+ type === 'success'
+ ? 'bg-green-900 text-green-200'
+ : type === 'error'
+ ? 'bg-red-900 text-red-200'
+ : 'bg-blue-900 text-blue-200'
+ }`;
+ statusMessage.classList.remove('hidden');
}
function hideStatus() {
- statusMessage.classList.add('hidden')
+ statusMessage.classList.add('hidden');
}
function updateFileList() {
- fileListDiv.innerHTML = ''
+ fileListDiv.innerHTML = '';
if (selectedFiles.length === 0) {
- fileListDiv.classList.add('hidden')
- return
+ fileListDiv.classList.add('hidden');
+ return;
}
- fileListDiv.classList.remove('hidden')
+ fileListDiv.classList.remove('hidden');
selectedFiles.forEach((file) => {
- const fileDiv = document.createElement('div')
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm mb-2'
+ const fileDiv = document.createElement('div');
+ fileDiv.className =
+ 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm mb-2';
- const nameSpan = document.createElement('span')
- nameSpan.className = 'truncate font-medium text-gray-200'
- nameSpan.textContent = file.name
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'truncate font-medium text-gray-200';
+ nameSpan.textContent = file.name;
- const sizeSpan = document.createElement('span')
- sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400'
- sizeSpan.textContent = formatBytes(file.size)
+ const sizeSpan = document.createElement('span');
+ sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400';
+ sizeSpan.textContent = formatBytes(file.size);
- fileDiv.append(nameSpan, sizeSpan)
- fileListDiv.appendChild(fileDiv)
- })
+ fileDiv.append(nameSpan, sizeSpan);
+ fileListDiv.appendChild(fileDiv);
+ });
}
jsonFilesInput.addEventListener('change', (e) => {
- const target = e.target as HTMLInputElement
+ const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
- selectedFiles = Array.from(target.files)
- convertBtn.disabled = selectedFiles.length === 0
- updateFileList()
+ selectedFiles = Array.from(target.files);
+ convertBtn.disabled = selectedFiles.length === 0;
+ updateFileList();
if (selectedFiles.length === 0) {
- showStatus('Please select at least 1 JSON file', 'info')
+ showStatus('Please select at least 1 JSON file', 'info');
} else {
- showStatus(`${selectedFiles.length} file(s) selected. Ready to convert!`, 'info')
+ showStatus(
+ `${selectedFiles.length} file(s) selected. Ready to convert!`,
+ 'info'
+ );
}
}
-})
+});
async function convertJSONsToPDF() {
if (selectedFiles.length === 0) {
- showStatus('Please select at least 1 JSON file', 'error')
- return
+ showStatus('Please select at least 1 JSON file', 'error');
+ return;
+ }
+
+ // Check if CPDF is configured
+ if (!isCpdfAvailable()) {
+ showWasmRequiredDialog('cpdf');
+ return;
}
try {
- convertBtn.disabled = true
- showStatus('Reading files (Main Thread)...', 'info')
+ convertBtn.disabled = true;
+ showStatus('Reading files (Main Thread)...', 'info');
const fileBuffers = await Promise.all(
- selectedFiles.map(file => readFileAsArrayBuffer(file))
- )
+ selectedFiles.map((file) => readFileAsArrayBuffer(file))
+ );
- showStatus('Converting JSONs to PDFs...', 'info')
-
- worker.postMessage({
- command: 'convert',
- fileBuffers: fileBuffers,
- fileNames: selectedFiles.map(f => f.name)
- }, fileBuffers);
+ showStatus('Converting JSONs to PDFs...', 'info');
+ worker.postMessage(
+ {
+ command: 'convert',
+ fileBuffers: fileBuffers,
+ fileNames: selectedFiles.map((f) => f.name),
+ cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js',
+ },
+ fileBuffers
+ );
} catch (error) {
- console.error('Error reading files:', error)
- showStatus(`โ Error reading files: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error')
- convertBtn.disabled = false
+ console.error('Error reading files:', error);
+ showStatus(
+ `โ Error reading files: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ 'error'
+ );
+ convertBtn.disabled = false;
}
}
@@ -103,42 +135,49 @@ worker.onmessage = async (e: MessageEvent) => {
convertBtn.disabled = false;
if (e.data.status === 'success') {
- const pdfFiles = e.data.pdfFiles as Array<{ name: string, data: ArrayBuffer }>;
+ const pdfFiles = e.data.pdfFiles as Array<{
+ name: string;
+ data: ArrayBuffer;
+ }>;
try {
- showStatus('Creating ZIP file...', 'info')
+ showStatus('Creating ZIP file...', 'info');
- const zip = new JSZip()
+ const zip = new JSZip();
pdfFiles.forEach(({ name, data }) => {
- const pdfName = name.replace(/\.json$/i, '.pdf')
- const uint8Array = new Uint8Array(data)
- zip.file(pdfName, uint8Array)
- })
+ const pdfName = name.replace(/\.json$/i, '.pdf');
+ const uint8Array = new Uint8Array(data);
+ zip.file(pdfName, uint8Array);
+ });
- const zipBlob = await zip.generateAsync({ type: 'blob' })
- const url = URL.createObjectURL(zipBlob)
- const a = document.createElement('a')
- a.href = url
- a.download = 'jsons-to-pdf.zip'
- downloadFile(zipBlob, 'jsons-to-pdf.zip')
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ const url = URL.createObjectURL(zipBlob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'jsons-to-pdf.zip';
+ downloadFile(zipBlob, 'jsons-to-pdf.zip');
- showStatus('โ
JSONs converted to PDF successfully! ZIP download started.', 'success')
+ showStatus(
+ 'โ
JSONs converted to PDF successfully! ZIP download started.',
+ 'success'
+ );
- selectedFiles = []
- jsonFilesInput.value = ''
- fileListDiv.innerHTML = ''
- fileListDiv.classList.add('hidden')
- convertBtn.disabled = true
+ selectedFiles = [];
+ jsonFilesInput.value = '';
+ fileListDiv.innerHTML = '';
+ fileListDiv.classList.add('hidden');
+ convertBtn.disabled = true;
setTimeout(() => {
- hideStatus()
- }, 3000)
-
+ hideStatus();
+ }, 3000);
} catch (error) {
- console.error('Error creating ZIP:', error)
- showStatus(`โ Error creating ZIP: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error')
+ console.error('Error creating ZIP:', error);
+ showStatus(
+ `โ Error creating ZIP: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ 'error'
+ );
}
-
} else if (e.data.status === 'error') {
const errorMessage = e.data.message || 'Unknown error occurred in worker.';
console.error('Worker Error:', errorMessage);
@@ -148,12 +187,12 @@ worker.onmessage = async (e: MessageEvent) => {
if (backToToolsBtn) {
backToToolsBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL
- })
+ window.location.href = import.meta.env.BASE_URL;
+ });
}
-convertBtn.addEventListener('click', convertJSONsToPDF)
+convertBtn.addEventListener('click', convertJSONsToPDF);
// Initialize
-showStatus('Select JSON files to get started', 'info')
-initializeGlobalShortcuts()
+showStatus('Select JSON files to get started', 'info');
+initializeGlobalShortcuts();
diff --git a/src/js/logic/merge-pdf-page.ts b/src/js/logic/merge-pdf-page.ts
index 85c6a23..e263fd5 100644
--- a/src/js/logic/merge-pdf-page.ts
+++ b/src/js/logic/merge-pdf-page.ts
@@ -1,629 +1,673 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
+import {
+ downloadFile,
+ readFileAsArrayBuffer,
+ getPDFDocument,
+} from '../utils/helpers.js';
import { state } from '../state.js';
-import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js';
+import {
+ renderPagesProgressively,
+ cleanupLazyRendering,
+} from '../utils/render-utils.js';
+import { isCpdfAvailable } from '../utils/cpdf-helper.js';
+import {
+ showWasmRequiredDialog,
+ WasmProvider,
+} from '../utils/wasm-provider.js';
import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
import Sortable from 'sortablejs';
// @ts-ignore
-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();
interface MergeState {
- pdfDocs: Record;
- pdfBytes: Record;
- activeMode: 'file' | 'page';
- sortableInstances: {
- fileList?: Sortable;
- pageThumbnails?: Sortable;
- };
- isRendering: boolean;
- cachedThumbnails: boolean | null;
- lastFileHash: string | null;
- mergeSuccess: boolean;
+ pdfDocs: Record;
+ pdfBytes: Record;
+ activeMode: 'file' | 'page';
+ sortableInstances: {
+ fileList?: Sortable;
+ pageThumbnails?: Sortable;
+ };
+ isRendering: boolean;
+ cachedThumbnails: boolean | null;
+ lastFileHash: string | null;
+ mergeSuccess: boolean;
}
const mergeState: MergeState = {
- pdfDocs: {},
- pdfBytes: {},
- activeMode: 'file',
- sortableInstances: {},
- isRendering: false,
- cachedThumbnails: null,
- lastFileHash: null,
- mergeSuccess: false,
+ pdfDocs: {},
+ pdfBytes: {},
+ activeMode: 'file',
+ sortableInstances: {},
+ isRendering: false,
+ cachedThumbnails: null,
+ lastFileHash: null,
+ mergeSuccess: false,
};
-const mergeWorker = new Worker(import.meta.env.BASE_URL + 'workers/merge.worker.js');
+const mergeWorker = new Worker(
+ import.meta.env.BASE_URL + 'workers/merge.worker.js'
+);
function initializeFileListSortable() {
- const fileList = document.getElementById('file-list');
- if (!fileList) return;
+ const fileList = document.getElementById('file-list');
+ if (!fileList) return;
- if (mergeState.sortableInstances.fileList) {
- mergeState.sortableInstances.fileList.destroy();
- }
+ if (mergeState.sortableInstances.fileList) {
+ mergeState.sortableInstances.fileList.destroy();
+ }
- mergeState.sortableInstances.fileList = Sortable.create(fileList, {
- handle: '.drag-handle',
- animation: 150,
- ghostClass: 'sortable-ghost',
- chosenClass: 'sortable-chosen',
- dragClass: 'sortable-drag',
- onStart: function (evt: any) {
- evt.item.style.opacity = '0.5';
- },
- onEnd: function (evt: any) {
- evt.item.style.opacity = '1';
- },
- });
+ mergeState.sortableInstances.fileList = Sortable.create(fileList, {
+ handle: '.drag-handle',
+ animation: 150,
+ ghostClass: 'sortable-ghost',
+ chosenClass: 'sortable-chosen',
+ dragClass: 'sortable-drag',
+ onStart: function (evt: any) {
+ evt.item.style.opacity = '0.5';
+ },
+ onEnd: function (evt: any) {
+ evt.item.style.opacity = '1';
+ },
+ });
}
function initializePageThumbnailsSortable() {
- const container = document.getElementById('page-merge-preview');
- if (!container) return;
+ const container = document.getElementById('page-merge-preview');
+ if (!container) return;
- if (mergeState.sortableInstances.pageThumbnails) {
- mergeState.sortableInstances.pageThumbnails.destroy();
- }
+ if (mergeState.sortableInstances.pageThumbnails) {
+ mergeState.sortableInstances.pageThumbnails.destroy();
+ }
- mergeState.sortableInstances.pageThumbnails = Sortable.create(container, {
- animation: 150,
- ghostClass: 'sortable-ghost',
- chosenClass: 'sortable-chosen',
- dragClass: 'sortable-drag',
- onStart: function (evt: any) {
- evt.item.style.opacity = '0.5';
- },
- onEnd: function (evt: any) {
- evt.item.style.opacity = '1';
- },
- });
+ mergeState.sortableInstances.pageThumbnails = Sortable.create(container, {
+ animation: 150,
+ ghostClass: 'sortable-ghost',
+ chosenClass: 'sortable-chosen',
+ dragClass: 'sortable-drag',
+ onStart: function (evt: any) {
+ evt.item.style.opacity = '0.5';
+ },
+ onEnd: function (evt: any) {
+ evt.item.style.opacity = '1';
+ },
+ });
}
function generateFileHash() {
- return (state.files as File[])
- .map((f) => `${f.name}-${f.size}-${f.lastModified}`)
- .join('|');
+ return (state.files as File[])
+ .map((f) => `${f.name}-${f.size}-${f.lastModified}`)
+ .join('|');
}
async function renderPageMergeThumbnails() {
- const container = document.getElementById('page-merge-preview');
- if (!container) return;
+ const container = document.getElementById('page-merge-preview');
+ if (!container) return;
- const currentFileHash = generateFileHash();
- const filesChanged = currentFileHash !== mergeState.lastFileHash;
+ const currentFileHash = generateFileHash();
+ const filesChanged = currentFileHash !== mergeState.lastFileHash;
- if (!filesChanged && mergeState.cachedThumbnails !== null) {
- // Simple check to see if it's already rendered to avoid flicker.
- if (container.firstChild) {
- initializePageThumbnailsSortable();
- return;
- }
+ if (!filesChanged && mergeState.cachedThumbnails !== null) {
+ // Simple check to see if it's already rendered to avoid flicker.
+ if (container.firstChild) {
+ initializePageThumbnailsSortable();
+ return;
}
+ }
- if (mergeState.isRendering) {
- return;
- }
+ if (mergeState.isRendering) {
+ return;
+ }
- mergeState.isRendering = true;
- container.textContent = '';
+ mergeState.isRendering = true;
+ container.textContent = '';
- cleanupLazyRendering();
+ cleanupLazyRendering();
- let totalPages = 0;
+ let totalPages = 0;
+ for (const file of state.files) {
+ const doc = mergeState.pdfDocs[file.name];
+ if (doc) totalPages += doc.numPages;
+ }
+
+ try {
+ let currentPageNumber = 0;
+
+ // Function to create wrapper element for each page
+ const createWrapper = (
+ canvas: HTMLCanvasElement,
+ pageNumber: number,
+ fileName?: string
+ ) => {
+ const wrapper = document.createElement('div');
+ wrapper.className =
+ 'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors';
+ wrapper.dataset.fileName = fileName || '';
+ wrapper.dataset.pageIndex = (pageNumber - 1).toString();
+
+ const imgContainer = document.createElement('div');
+ imgContainer.className = 'relative';
+
+ const img = document.createElement('img');
+ img.src = canvas.toDataURL();
+ img.className = 'rounded-md shadow-md max-w-full h-auto';
+
+ const pageNumDiv = document.createElement('div');
+ pageNumDiv.className =
+ 'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg';
+ pageNumDiv.textContent = pageNumber.toString();
+
+ imgContainer.append(img, pageNumDiv);
+
+ const fileNamePara = document.createElement('p');
+ fileNamePara.className =
+ 'text-xs text-gray-400 truncate w-full text-center';
+ const fullTitle = fileName
+ ? `${fileName} (page ${pageNumber})`
+ : `Page ${pageNumber}`;
+ fileNamePara.title = fullTitle;
+ fileNamePara.textContent = fileName
+ ? `${fileName.substring(0, 10)}... (p${pageNumber})`
+ : `Page ${pageNumber}`;
+
+ wrapper.append(imgContainer, fileNamePara);
+ return wrapper;
+ };
+
+ // Render pages from all files progressively
for (const file of state.files) {
- const doc = mergeState.pdfDocs[file.name];
- if (doc) totalPages += doc.numPages;
- }
+ const pdfjsDoc = mergeState.pdfDocs[file.name];
+ if (!pdfjsDoc) continue;
- try {
- let currentPageNumber = 0;
+ // Create a wrapper function that includes the file name
+ const createWrapperWithFileName = (
+ canvas: HTMLCanvasElement,
+ pageNumber: number
+ ) => {
+ return createWrapper(canvas, pageNumber, file.name);
+ };
- // Function to create wrapper element for each page
- const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number, fileName?: string) => {
- const wrapper = document.createElement('div');
- wrapper.className =
- 'page-thumbnail relative cursor-move flex flex-col items-center gap-1 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors';
- wrapper.dataset.fileName = fileName || '';
- wrapper.dataset.pageIndex = (pageNumber - 1).toString();
-
- const imgContainer = document.createElement('div');
- imgContainer.className = 'relative';
-
- const img = document.createElement('img');
- img.src = canvas.toDataURL();
- img.className = 'rounded-md shadow-md max-w-full h-auto';
-
- const pageNumDiv = document.createElement('div');
- pageNumDiv.className =
- 'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg';
- pageNumDiv.textContent = pageNumber.toString();
-
- imgContainer.append(img, pageNumDiv);
-
- const fileNamePara = document.createElement('p');
- fileNamePara.className =
- 'text-xs text-gray-400 truncate w-full text-center';
- const fullTitle = fileName ? `${fileName} (page ${pageNumber})` : `Page ${pageNumber}`;
- fileNamePara.title = fullTitle;
- fileNamePara.textContent = fileName
- ? `${fileName.substring(0, 10)}... (p${pageNumber})`
- : `Page ${pageNumber}`;
-
- wrapper.append(imgContainer, fileNamePara);
- return wrapper;
- };
-
- // Render pages from all files progressively
- for (const file of state.files) {
- const pdfjsDoc = mergeState.pdfDocs[file.name];
- if (!pdfjsDoc) continue;
-
- // Create a wrapper function that includes the file name
- const createWrapperWithFileName = (canvas: HTMLCanvasElement, pageNumber: number) => {
- return createWrapper(canvas, pageNumber, file.name);
- };
-
- // Render pages progressively with lazy loading
- await renderPagesProgressively(
- pdfjsDoc,
- container,
- createWrapperWithFileName,
- {
- batchSize: 8,
- useLazyLoading: true,
- lazyLoadMargin: '300px',
- onProgress: (current, total) => {
- currentPageNumber++;
- showLoader(
- `Rendering page previews...`
- );
- },
- onBatchComplete: () => {
- createIcons({ icons });
- }
- }
- );
+ // Render pages progressively with lazy loading
+ await renderPagesProgressively(
+ pdfjsDoc,
+ container,
+ createWrapperWithFileName,
+ {
+ batchSize: 8,
+ useLazyLoading: true,
+ lazyLoadMargin: '300px',
+ onProgress: (current, total) => {
+ currentPageNumber++;
+ showLoader(`Rendering page previews...`);
+ },
+ onBatchComplete: () => {
+ createIcons({ icons });
+ },
}
-
- mergeState.cachedThumbnails = true;
- mergeState.lastFileHash = currentFileHash;
-
- initializePageThumbnailsSortable();
- } catch (error) {
- console.error('Error rendering page thumbnails:', error);
- showAlert('Error', 'Failed to render page thumbnails');
- } finally {
- hideLoader();
- mergeState.isRendering = false;
+ );
}
+
+ mergeState.cachedThumbnails = true;
+ mergeState.lastFileHash = currentFileHash;
+
+ initializePageThumbnailsSortable();
+ } catch (error) {
+ console.error('Error rendering page thumbnails:', error);
+ showAlert('Error', 'Failed to render page thumbnails');
+ } finally {
+ hideLoader();
+ mergeState.isRendering = false;
+ }
}
const updateUI = async () => {
- const fileControls = document.getElementById('file-controls');
- const mergeOptions = document.getElementById('merge-options');
+ const fileControls = document.getElementById('file-controls');
+ const mergeOptions = document.getElementById('merge-options');
- if (state.files.length > 0) {
- if (fileControls) fileControls.classList.remove('hidden');
- if (mergeOptions) mergeOptions.classList.remove('hidden');
- await refreshMergeUI();
- } else {
- if (fileControls) fileControls.classList.add('hidden');
- if (mergeOptions) mergeOptions.classList.add('hidden');
- // Clear file list UI
- const fileList = document.getElementById('file-list');
- if (fileList) fileList.innerHTML = '';
- }
+ if (state.files.length > 0) {
+ if (fileControls) fileControls.classList.remove('hidden');
+ if (mergeOptions) mergeOptions.classList.remove('hidden');
+ await refreshMergeUI();
+ } else {
+ if (fileControls) fileControls.classList.add('hidden');
+ if (mergeOptions) mergeOptions.classList.add('hidden');
+ // Clear file list UI
+ const fileList = document.getElementById('file-list');
+ if (fileList) fileList.innerHTML = '';
+ }
};
const resetState = async () => {
- state.files = [];
- state.pdfDoc = null;
+ state.files = [];
+ state.pdfDoc = null;
- mergeState.pdfDocs = {};
- mergeState.pdfBytes = {};
- mergeState.activeMode = 'file';
- mergeState.cachedThumbnails = null;
- mergeState.lastFileHash = null;
- mergeState.mergeSuccess = false;
+ mergeState.pdfDocs = {};
+ mergeState.pdfBytes = {};
+ mergeState.activeMode = 'file';
+ mergeState.cachedThumbnails = null;
+ mergeState.lastFileHash = null;
+ mergeState.mergeSuccess = false;
- const fileList = document.getElementById('file-list');
- if (fileList) fileList.innerHTML = '';
+ const fileList = document.getElementById('file-list');
+ if (fileList) fileList.innerHTML = '';
- const pageMergePreview = document.getElementById('page-merge-preview');
- if (pageMergePreview) pageMergePreview.innerHTML = '';
+ const pageMergePreview = document.getElementById('page-merge-preview');
+ if (pageMergePreview) pageMergePreview.innerHTML = '';
- const fileModeBtn = document.getElementById('file-mode-btn');
- const pageModeBtn = document.getElementById('page-mode-btn');
- const filePanel = document.getElementById('file-mode-panel');
- const pagePanel = document.getElementById('page-mode-panel');
+ const fileModeBtn = document.getElementById('file-mode-btn');
+ const pageModeBtn = document.getElementById('page-mode-btn');
+ const filePanel = document.getElementById('file-mode-panel');
+ const pagePanel = document.getElementById('page-mode-panel');
- if (fileModeBtn && pageModeBtn && filePanel && pagePanel) {
- fileModeBtn.classList.add('bg-indigo-600', 'text-white');
- fileModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
- pageModeBtn.classList.remove('bg-indigo-600', 'text-white');
- pageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
+ if (fileModeBtn && pageModeBtn && filePanel && pagePanel) {
+ fileModeBtn.classList.add('bg-indigo-600', 'text-white');
+ fileModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
+ pageModeBtn.classList.remove('bg-indigo-600', 'text-white');
+ pageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
- filePanel.classList.remove('hidden');
- pagePanel.classList.add('hidden');
- }
+ filePanel.classList.remove('hidden');
+ pagePanel.classList.add('hidden');
+ }
- await updateUI();
+ await updateUI();
};
-
export async function merge() {
- showLoader('Merging PDFs...');
- try {
- // @ts-ignore
- const jobs: MergeJob[] = [];
- // @ts-ignore
- const filesToMerge: MergeFile[] = [];
- const uniqueFileNames = new Set();
+ // Check if CPDF is configured
+ if (!isCpdfAvailable()) {
+ showWasmRequiredDialog('cpdf');
+ return;
+ }
- if (mergeState.activeMode === 'file') {
- const fileList = document.getElementById('file-list');
- if (!fileList) throw new Error('File list not found');
+ showLoader('Merging PDFs...');
+ try {
+ // @ts-ignore
+ const jobs: MergeJob[] = [];
+ // @ts-ignore
+ const filesToMerge: MergeFile[] = [];
+ const uniqueFileNames = new Set();
- const sortedFiles = Array.from(fileList.children)
- .map((li) => {
- return state.files.find((f) => f.name === (li as HTMLElement).dataset.fileName);
- })
- .filter(Boolean);
+ if (mergeState.activeMode === 'file') {
+ const fileList = document.getElementById('file-list');
+ if (!fileList) throw new Error('File list not found');
- for (const file of sortedFiles) {
- if (!file) continue;
- const safeFileName = file.name.replace(/[^a-zA-Z0-9]/g, '_');
- const rangeInput = document.getElementById(`range-${safeFileName}`) as HTMLInputElement;
+ const sortedFiles = Array.from(fileList.children)
+ .map((li) => {
+ return state.files.find(
+ (f) => f.name === (li as HTMLElement).dataset.fileName
+ );
+ })
+ .filter(Boolean);
- uniqueFileNames.add(file.name);
+ for (const file of sortedFiles) {
+ if (!file) continue;
+ const safeFileName = file.name.replace(/[^a-zA-Z0-9]/g, '_');
+ const rangeInput = document.getElementById(
+ `range-${safeFileName}`
+ ) as HTMLInputElement;
- if (rangeInput && rangeInput.value.trim()) {
- jobs.push({
- fileName: file.name,
- rangeType: 'specific',
- rangeString: rangeInput.value.trim()
- });
- } else {
- jobs.push({
- fileName: file.name,
- rangeType: 'all'
- });
- }
- }
+ uniqueFileNames.add(file.name);
+
+ if (rangeInput && rangeInput.value.trim()) {
+ jobs.push({
+ fileName: file.name,
+ rangeType: 'specific',
+ rangeString: rangeInput.value.trim(),
+ });
} else {
- // Page Mode
- const pageContainer = document.getElementById('page-merge-preview');
- if (!pageContainer) throw new Error('Page container not found');
- const pageElements = Array.from(pageContainer.children);
+ jobs.push({
+ fileName: file.name,
+ rangeType: 'all',
+ });
+ }
+ }
+ } else {
+ // Page Mode
+ const pageContainer = document.getElementById('page-merge-preview');
+ if (!pageContainer) throw new Error('Page container not found');
+ const pageElements = Array.from(pageContainer.children);
- const rawPages: { fileName: string; pageIndex: number }[] = [];
- for (const el of pageElements) {
- const element = el as HTMLElement;
- const fileName = element.dataset.fileName;
- const pageIndex = parseInt(element.dataset.pageIndex || '', 10); // 0-based index from dataset
+ const rawPages: { fileName: string; pageIndex: number }[] = [];
+ for (const el of pageElements) {
+ const element = el as HTMLElement;
+ const fileName = element.dataset.fileName;
+ const pageIndex = parseInt(element.dataset.pageIndex || '', 10); // 0-based index from dataset
- if (fileName && !isNaN(pageIndex)) {
- uniqueFileNames.add(fileName);
- rawPages.push({ fileName, pageIndex });
- }
- }
+ if (fileName && !isNaN(pageIndex)) {
+ uniqueFileNames.add(fileName);
+ rawPages.push({ fileName, pageIndex });
+ }
+ }
- // Group contiguous pages
- for (let i = 0; i < rawPages.length; i++) {
- const current = rawPages[i];
- let endPage = current.pageIndex;
+ // Group contiguous pages
+ for (let i = 0; i < rawPages.length; i++) {
+ const current = rawPages[i];
+ let endPage = current.pageIndex;
- while (
- i + 1 < rawPages.length &&
- rawPages[i + 1].fileName === current.fileName &&
- rawPages[i + 1].pageIndex === endPage + 1
- ) {
- endPage++;
- i++;
- }
-
- if (endPage === current.pageIndex) {
- // Single page
- jobs.push({
- fileName: current.fileName,
- rangeType: 'single',
- pageIndex: current.pageIndex
- });
- } else {
- // Range of pages
- jobs.push({
- fileName: current.fileName,
- rangeType: 'range',
- startPage: current.pageIndex + 1,
- endPage: endPage + 1
- });
- }
- }
+ while (
+ i + 1 < rawPages.length &&
+ rawPages[i + 1].fileName === current.fileName &&
+ rawPages[i + 1].pageIndex === endPage + 1
+ ) {
+ endPage++;
+ i++;
}
- if (jobs.length === 0) {
- showAlert('Error', 'No files or pages selected to merge.');
- hideLoader();
- return;
+ if (endPage === current.pageIndex) {
+ // Single page
+ jobs.push({
+ fileName: current.fileName,
+ rangeType: 'single',
+ pageIndex: current.pageIndex,
+ });
+ } else {
+ // Range of pages
+ jobs.push({
+ fileName: current.fileName,
+ rangeType: 'range',
+ startPage: current.pageIndex + 1,
+ endPage: endPage + 1,
+ });
}
-
- for (const name of uniqueFileNames) {
- const bytes = mergeState.pdfBytes[name];
- if (bytes) {
- filesToMerge.push({ name, data: bytes });
- }
- }
-
- // @ts-ignore
- const message: MergeMessage = {
- command: 'merge',
- files: filesToMerge,
- jobs: jobs
- };
-
- mergeWorker.postMessage(message, filesToMerge.map(f => f.data));
-
- // @ts-ignore
- mergeWorker.onmessage = (e: MessageEvent) => {
- hideLoader();
- if (e.data.status === 'success') {
- const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });
- downloadFile(blob, 'merged.pdf');
- mergeState.mergeSuccess = true;
- showAlert('Success', 'PDFs merged successfully!', 'success', async () => {
- await resetState();
- });
- } else {
- console.error('Worker merge error:', e.data.message);
- showAlert('Error', e.data.message || 'Failed to merge PDFs.');
- }
- };
-
- mergeWorker.onerror = (e) => {
- hideLoader();
- console.error('Worker error:', e);
- showAlert('Error', 'An unexpected error occurred in the merge worker.');
- };
-
- } catch (e) {
- console.error('Merge error:', e);
- showAlert(
- 'Error',
- 'Failed to merge PDFs. Please check that all files are valid and not password-protected.'
- );
- hideLoader();
+ }
}
+
+ if (jobs.length === 0) {
+ showAlert('Error', 'No files or pages selected to merge.');
+ hideLoader();
+ return;
+ }
+
+ for (const name of uniqueFileNames) {
+ const bytes = mergeState.pdfBytes[name];
+ if (bytes) {
+ filesToMerge.push({ name, data: bytes });
+ }
+ }
+
+ // @ts-ignore
+ const message: MergeMessage = {
+ command: 'merge',
+ files: filesToMerge,
+ jobs: jobs,
+ cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js',
+ };
+
+ mergeWorker.postMessage(
+ message,
+ filesToMerge.map((f) => f.data)
+ );
+
+ // @ts-ignore
+ mergeWorker.onmessage = (e: MessageEvent) => {
+ hideLoader();
+ if (e.data.status === 'success') {
+ const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });
+ downloadFile(blob, 'merged.pdf');
+ mergeState.mergeSuccess = true;
+ showAlert(
+ 'Success',
+ 'PDFs merged successfully!',
+ 'success',
+ async () => {
+ await resetState();
+ }
+ );
+ } else {
+ console.error('Worker merge error:', e.data.message);
+ showAlert('Error', e.data.message || 'Failed to merge PDFs.');
+ }
+ };
+
+ mergeWorker.onerror = (e) => {
+ hideLoader();
+ console.error('Worker error:', e);
+ showAlert('Error', 'An unexpected error occurred in the merge worker.');
+ };
+ } catch (e) {
+ console.error('Merge error:', e);
+ showAlert(
+ 'Error',
+ 'Failed to merge PDFs. Please check that all files are valid and not password-protected.'
+ );
+ hideLoader();
+ }
}
export async function refreshMergeUI() {
- document.getElementById('merge-options')?.classList.remove('hidden');
- const processBtn = document.getElementById('process-btn') as HTMLButtonElement;
- if (processBtn) processBtn.disabled = false;
+ document.getElementById('merge-options')?.classList.remove('hidden');
+ const processBtn = document.getElementById(
+ 'process-btn'
+ ) as HTMLButtonElement;
+ if (processBtn) processBtn.disabled = false;
- const wasInPageMode = mergeState.activeMode === 'page';
+ const wasInPageMode = mergeState.activeMode === 'page';
- showLoader('Loading PDF documents...');
- try {
- mergeState.pdfDocs = {};
- mergeState.pdfBytes = {};
+ showLoader('Loading PDF documents...');
+ try {
+ mergeState.pdfDocs = {};
+ mergeState.pdfBytes = {};
- for (const file of state.files) {
- const pdfBytes = await readFileAsArrayBuffer(file);
- mergeState.pdfBytes[file.name] = pdfBytes as ArrayBuffer;
+ for (const file of state.files) {
+ const pdfBytes = await readFileAsArrayBuffer(file);
+ mergeState.pdfBytes[file.name] = pdfBytes as ArrayBuffer;
- const bytesForPdfJs = (pdfBytes as ArrayBuffer).slice(0);
- const pdfjsDoc = await getPDFDocument({ data: bytesForPdfJs }).promise;
- mergeState.pdfDocs[file.name] = pdfjsDoc;
- }
- } catch (error) {
- console.error('Error loading PDFs:', error);
- showAlert('Error', 'Failed to load one or more PDF files');
- return;
- } finally {
- hideLoader();
+ const bytesForPdfJs = (pdfBytes as ArrayBuffer).slice(0);
+ const pdfjsDoc = await getPDFDocument({ data: bytesForPdfJs }).promise;
+ mergeState.pdfDocs[file.name] = pdfjsDoc;
}
+ } catch (error) {
+ console.error('Error loading PDFs:', error);
+ showAlert('Error', 'Failed to load one or more PDF files');
+ return;
+ } finally {
+ hideLoader();
+ }
- const fileModeBtn = document.getElementById('file-mode-btn');
- const pageModeBtn = document.getElementById('page-mode-btn');
- const filePanel = document.getElementById('file-mode-panel');
- const pagePanel = document.getElementById('page-mode-panel');
- const fileList = document.getElementById('file-list');
+ const fileModeBtn = document.getElementById('file-mode-btn');
+ const pageModeBtn = document.getElementById('page-mode-btn');
+ const filePanel = document.getElementById('file-mode-panel');
+ const pagePanel = document.getElementById('page-mode-panel');
+ const fileList = document.getElementById('file-list');
- if (!fileModeBtn || !pageModeBtn || !filePanel || !pagePanel || !fileList) return;
+ if (!fileModeBtn || !pageModeBtn || !filePanel || !pagePanel || !fileList)
+ return;
- fileList.textContent = ''; // Clear list safely
- (state.files as File[]).forEach((f, index) => {
- const doc = mergeState.pdfDocs[f.name];
- const pageCount = doc ? doc.numPages : 'N/A';
- const safeFileName = f.name.replace(/[^a-zA-Z0-9]/g, '_');
+ fileList.textContent = ''; // Clear list safely
+ (state.files as File[]).forEach((f, index) => {
+ const doc = mergeState.pdfDocs[f.name];
+ const pageCount = doc ? doc.numPages : 'N/A';
+ const safeFileName = f.name.replace(/[^a-zA-Z0-9]/g, '_');
- const li = document.createElement('li');
- li.className =
- 'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors';
- li.dataset.fileName = f.name;
+ const li = document.createElement('li');
+ li.className =
+ 'bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors';
+ li.dataset.fileName = f.name;
- const mainDiv = document.createElement('div');
- mainDiv.className = 'flex items-center justify-between';
+ const mainDiv = document.createElement('div');
+ mainDiv.className = 'flex items-center justify-between';
- const nameSpan = document.createElement('span');
- nameSpan.className = 'truncate font-medium text-white flex-1 mr-2';
- nameSpan.title = f.name;
- nameSpan.textContent = f.name;
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'truncate font-medium text-white flex-1 mr-2';
+ nameSpan.title = f.name;
+ nameSpan.textContent = f.name;
- const dragHandle = document.createElement('div');
- dragHandle.className =
- 'drag-handle cursor-move text-gray-400 hover:text-white p-1 rounded transition-colors';
- dragHandle.innerHTML = ` `; // Safe: static content
+ const dragHandle = document.createElement('div');
+ dragHandle.className =
+ 'drag-handle cursor-move text-gray-400 hover:text-white p-1 rounded transition-colors';
+ dragHandle.innerHTML = ` `; // Safe: static content
- mainDiv.append(nameSpan, dragHandle);
+ mainDiv.append(nameSpan, dragHandle);
- const rangeDiv = document.createElement('div');
- rangeDiv.className = 'mt-2 flex items-center gap-2';
+ const rangeDiv = document.createElement('div');
+ rangeDiv.className = 'mt-2 flex items-center gap-2';
- const inputWrapper = document.createElement('div');
- inputWrapper.className = 'flex-1';
+ const inputWrapper = document.createElement('div');
+ inputWrapper.className = 'flex-1';
- const label = document.createElement('label');
- label.htmlFor = `range-${safeFileName}`;
- label.className = 'text-xs text-gray-400';
- label.textContent = `Pages (e.g., 1-3, 5) - Total: ${pageCount}`;
+ const label = document.createElement('label');
+ label.htmlFor = `range-${safeFileName}`;
+ label.className = 'text-xs text-gray-400';
+ label.textContent = `Pages (e.g., 1-3, 5) - Total: ${pageCount}`;
- const input = document.createElement('input');
- input.type = 'text';
- input.id = `range-${safeFileName}`;
- input.className =
- 'w-full bg-gray-800 border border-gray-600 text-white rounded-md p-2 text-sm mt-1 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors';
- input.placeholder = 'Leave blank for all pages';
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.id = `range-${safeFileName}`;
+ input.className =
+ 'w-full bg-gray-800 border border-gray-600 text-white rounded-md p-2 text-sm mt-1 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors';
+ input.placeholder = 'Leave blank for all pages';
- inputWrapper.append(label, input);
+ inputWrapper.append(label, input);
- const deleteBtn = document.createElement('button');
- deleteBtn.className = 'text-red-400 hover:text-red-300 p-2 flex-shrink-0 self-end';
- deleteBtn.innerHTML = ' ';
- deleteBtn.title = 'Remove file';
- deleteBtn.onclick = (e) => {
- e.stopPropagation();
- state.files = state.files.filter((_, i) => i !== index);
- updateUI();
- };
+ const deleteBtn = document.createElement('button');
+ deleteBtn.className =
+ 'text-red-400 hover:text-red-300 p-2 flex-shrink-0 self-end';
+ deleteBtn.innerHTML = ' ';
+ deleteBtn.title = 'Remove file';
+ deleteBtn.onclick = (e) => {
+ e.stopPropagation();
+ state.files = state.files.filter((_, i) => i !== index);
+ updateUI();
+ };
- rangeDiv.append(inputWrapper, deleteBtn);
- li.append(mainDiv, rangeDiv);
- fileList.appendChild(li);
- });
+ rangeDiv.append(inputWrapper, deleteBtn);
+ li.append(mainDiv, rangeDiv);
+ fileList.appendChild(li);
+ });
- createIcons({ icons });
- initializeFileListSortable();
+ createIcons({ icons });
+ initializeFileListSortable();
- const newFileModeBtn = fileModeBtn.cloneNode(true) as HTMLElement;
- const newPageModeBtn = pageModeBtn.cloneNode(true) as HTMLElement;
- fileModeBtn.replaceWith(newFileModeBtn);
- pageModeBtn.replaceWith(newPageModeBtn);
+ const newFileModeBtn = fileModeBtn.cloneNode(true) as HTMLElement;
+ const newPageModeBtn = pageModeBtn.cloneNode(true) as HTMLElement;
+ fileModeBtn.replaceWith(newFileModeBtn);
+ pageModeBtn.replaceWith(newPageModeBtn);
- newFileModeBtn.addEventListener('click', () => {
- if (mergeState.activeMode === 'file') return;
+ newFileModeBtn.addEventListener('click', () => {
+ if (mergeState.activeMode === 'file') return;
- mergeState.activeMode = 'file';
- filePanel.classList.remove('hidden');
- pagePanel.classList.add('hidden');
+ mergeState.activeMode = 'file';
+ filePanel.classList.remove('hidden');
+ pagePanel.classList.add('hidden');
- newFileModeBtn.classList.add('bg-indigo-600', 'text-white');
- newFileModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
- newPageModeBtn.classList.remove('bg-indigo-600', 'text-white');
- newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
- });
+ newFileModeBtn.classList.add('bg-indigo-600', 'text-white');
+ newFileModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
+ newPageModeBtn.classList.remove('bg-indigo-600', 'text-white');
+ newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
+ });
- newPageModeBtn.addEventListener('click', async () => {
- if (mergeState.activeMode === 'page') return;
+ newPageModeBtn.addEventListener('click', async () => {
+ if (mergeState.activeMode === 'page') return;
- mergeState.activeMode = 'page';
- filePanel.classList.add('hidden');
- pagePanel.classList.remove('hidden');
+ mergeState.activeMode = 'page';
+ filePanel.classList.add('hidden');
+ pagePanel.classList.remove('hidden');
- newPageModeBtn.classList.add('bg-indigo-600', 'text-white');
- newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
- newFileModeBtn.classList.remove('bg-indigo-600', 'text-white');
- newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300');
+ newPageModeBtn.classList.add('bg-indigo-600', 'text-white');
+ newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
+ newFileModeBtn.classList.remove('bg-indigo-600', 'text-white');
+ newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300');
- await renderPageMergeThumbnails();
- });
+ await renderPageMergeThumbnails();
+ });
- if (wasInPageMode) {
- mergeState.activeMode = 'page';
- filePanel.classList.add('hidden');
- pagePanel.classList.remove('hidden');
+ if (wasInPageMode) {
+ mergeState.activeMode = 'page';
+ filePanel.classList.add('hidden');
+ pagePanel.classList.remove('hidden');
- newPageModeBtn.classList.add('bg-indigo-600', 'text-white');
- newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
- newFileModeBtn.classList.remove('bg-indigo-600', 'text-white');
- newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300');
+ newPageModeBtn.classList.add('bg-indigo-600', 'text-white');
+ newPageModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
+ newFileModeBtn.classList.remove('bg-indigo-600', 'text-white');
+ newFileModeBtn.classList.add('bg-gray-700', 'text-gray-300');
- await renderPageMergeThumbnails();
- } else {
- newFileModeBtn.classList.add('bg-indigo-600', 'text-white');
- newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
- }
+ await renderPageMergeThumbnails();
+ } else {
+ newFileModeBtn.classList.add('bg-indigo-600', 'text-white');
+ newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
+ }
}
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
- const fileControls = document.getElementById('file-controls');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const backBtn = document.getElementById('back-to-tools');
- const mergeOptions = document.getElementById('merge-options');
+ const fileControls = document.getElementById('file-controls');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const backBtn = document.getElementById('back-to-tools');
+ const mergeOptions = document.getElementById('merge-options');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
- }
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', async (e) => {
+ const files = (e.target as HTMLInputElement).files;
+ if (files && files.length > 0) {
+ state.files = [...state.files, ...Array.from(files)];
+ await updateUI();
+ }
+ });
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', async (e) => {
- const files = (e.target as HTMLInputElement).files;
- if (files && files.length > 0) {
- state.files = [...state.files, ...Array.from(files)];
- await updateUI();
- }
- });
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
+ dropZone.addEventListener('drop', async (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(
+ (f) =>
+ f.type === 'application/pdf' ||
+ f.name.toLowerCase().endsWith('.pdf')
+ );
+ if (pdfFiles.length > 0) {
+ state.files = [...state.files, ...pdfFiles];
+ await updateUI();
+ }
+ }
+ });
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
- dropZone.addEventListener('drop', async (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(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
- if (pdfFiles.length > 0) {
- state.files = [...state.files, ...pdfFiles];
- await updateUI();
- }
- }
- });
-
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
- }
-
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput.value = '';
- fileInput.click();
- });
- }
-
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', async () => {
- state.files = [];
- await updateUI();
- });
- }
-
- if (processBtn) {
- processBtn.addEventListener('click', async () => {
- await merge();
- });
- }
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput.value = '';
+ fileInput.click();
+ });
+ }
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', async () => {
+ state.files = [];
+ await updateUI();
+ });
+ }
+ if (processBtn) {
+ processBtn.addEventListener('click', async () => {
+ await merge();
+ });
+ }
});
diff --git a/src/js/logic/mobi-to-pdf-page.ts b/src/js/logic/mobi-to-pdf-page.ts
index f55dcc6..7ddd5c1 100644
--- a/src/js/logic/mobi-to-pdf-page.ts
+++ b/src/js/logic/mobi-to-pdf-page.ts
@@ -2,201 +2,212 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
const FILETYPE = 'mobi';
const EXTENSIONS = ['.mobi'];
const TOOL_NAME = 'MOBI';
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const fileDisplayArea = document.getElementById('file-display-area');
- const fileControls = document.getElementById('file-controls');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const backBtn = document.getElementById('back-to-tools');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const fileControls = document.getElementById('file-controls');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const backBtn = document.getElementById('back-to-tools');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
+
+ const updateUI = async () => {
+ if (!fileDisplayArea || !processBtn || !fileControls) return;
+
+ if (state.files.length > 0) {
+ fileDisplayArea.innerHTML = '';
+
+ for (let index = 0; index < state.files.length; index++) {
+ const file = state.files[index];
+ 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 nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
+
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = formatBytes(file.size);
+
+ 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 = () => {
+ state.files = state.files.filter((_, i) => i !== index);
+ updateUI();
+ };
+
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ }
+
+ createIcons({ icons });
+ fileControls.classList.remove('hidden');
+ processBtn.classList.remove('hidden');
+ (processBtn as HTMLButtonElement).disabled = false;
+ } else {
+ fileDisplayArea.innerHTML = '';
+ fileControls.classList.add('hidden');
+ processBtn.classList.add('hidden');
+ (processBtn as HTMLButtonElement).disabled = true;
}
+ };
- const updateUI = async () => {
- if (!fileDisplayArea || !processBtn || !fileControls) return;
+ const resetState = () => {
+ state.files = [];
+ state.pdfDoc = null;
+ updateUI();
+ };
- if (state.files.length > 0) {
- fileDisplayArea.innerHTML = '';
+ const convertToPdf = async () => {
+ try {
+ if (state.files.length === 0) {
+ showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
+ return;
+ }
- for (let index = 0; index < state.files.length; index++) {
- const file = state.files[index];
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ showLoader('Loading engine...');
+ const pymupdf = await loadPyMuPDF();
- const infoContainer = document.createElement('div');
- infoContainer.className = 'flex flex-col overflow-hidden';
+ if (state.files.length === 1) {
+ const originalFile = state.files[0];
+ showLoader(`Converting ${originalFile.name}...`);
- const nameSpan = document.createElement('div');
- nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
- nameSpan.textContent = file.name;
+ const pdfBlob = await pymupdf.convertToPdf(originalFile, {
+ filetype: FILETYPE,
+ });
+ const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = formatBytes(file.size);
+ downloadFile(pdfBlob, fileName);
+ hideLoader();
- infoContainer.append(nameSpan, metaSpan);
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${originalFile.name} to PDF.`,
+ 'success',
+ () => resetState()
+ );
+ } else {
+ showLoader('Converting files...');
+ const JSZip = (await import('jszip')).default;
+ const zip = new JSZip();
- const removeBtn = document.createElement('button');
- removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
- removeBtn.innerHTML = ' ';
- removeBtn.onclick = () => {
- state.files = state.files.filter((_, i) => i !== index);
- updateUI();
- };
+ for (let i = 0; i < state.files.length; i++) {
+ const file = state.files[i];
+ showLoader(
+ `Converting ${i + 1}/${state.files.length}: ${file.name}...`
+ );
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
- }
-
- createIcons({ icons });
- fileControls.classList.remove('hidden');
- processBtn.classList.remove('hidden');
- (processBtn as HTMLButtonElement).disabled = false;
- } else {
- fileDisplayArea.innerHTML = '';
- fileControls.classList.add('hidden');
- processBtn.classList.add('hidden');
- (processBtn as HTMLButtonElement).disabled = true;
+ const pdfBlob = await pymupdf.convertToPdf(file, {
+ filetype: FILETYPE,
+ });
+ const baseName = file.name.replace(/\.[^.]+$/, '');
+ const pdfBuffer = await pdfBlob.arrayBuffer();
+ zip.file(`${baseName}.pdf`, pdfBuffer);
}
- };
- const resetState = () => {
- state.files = [];
- state.pdfDoc = null;
- updateUI();
- };
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, `${FILETYPE}-converted.zip`);
- const convertToPdf = async () => {
- try {
- if (state.files.length === 0) {
- showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
- return;
- }
+ hideLoader();
- showLoader('Loading engine...');
- const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
- await pymupdf.load();
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
+ 'success',
+ () => resetState()
+ );
+ }
+ } catch (e: any) {
+ console.error(`[${TOOL_NAME}2PDF] ERROR:`, e);
+ hideLoader();
+ showAlert(
+ 'Error',
+ `An error occurred during conversion. Error: ${e.message}`
+ );
+ }
+ };
- if (state.files.length === 1) {
- const originalFile = state.files[0];
- showLoader(`Converting ${originalFile.name}...`);
+ const handleFileSelect = (files: FileList | null) => {
+ if (files && files.length > 0) {
+ state.files = [...state.files, ...Array.from(files)];
+ updateUI();
+ }
+ };
- const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE });
- const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
- downloadFile(pdfBlob, fileName);
- hideLoader();
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
- showAlert(
- 'Conversion Complete',
- `Successfully converted ${originalFile.name} to PDF.`,
- 'success',
- () => resetState()
- );
- } else {
- showLoader('Converting files...');
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
- for (let i = 0; i < state.files.length; i++) {
- const file = state.files[i];
- showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
-
- const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE });
- const baseName = file.name.replace(/\.[^.]+$/, '');
- const pdfBuffer = await pdfBlob.arrayBuffer();
- zip.file(`${baseName}.pdf`, pdfBuffer);
- }
-
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- downloadFile(zipBlob, `${FILETYPE}-converted.zip`);
-
- hideLoader();
-
- showAlert(
- 'Conversion Complete',
- `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
- 'success',
- () => resetState()
- );
- }
- } catch (e: any) {
- console.error(`[${TOOL_NAME}2PDF] ERROR:`, e);
- hideLoader();
- showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ const files = e.dataTransfer?.files;
+ if (files && files.length > 0) {
+ const validFiles = Array.from(files).filter((f) => {
+ const name = f.name.toLowerCase();
+ return EXTENSIONS.some((ext) => name.endsWith(ext));
+ });
+ if (validFiles.length > 0) {
+ const dataTransfer = new DataTransfer();
+ validFiles.forEach((f) => dataTransfer.items.add(f));
+ handleFileSelect(dataTransfer.files);
}
- };
+ }
+ });
- const handleFileSelect = (files: FileList | null) => {
- if (files && files.length > 0) {
- state.files = [...state.files, ...Array.from(files)];
- updateUI();
- }
- };
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', () => {
+ resetState();
+ });
+ }
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- const files = e.dataTransfer?.files;
- if (files && files.length > 0) {
- const validFiles = Array.from(files).filter(f => {
- const name = f.name.toLowerCase();
- return EXTENSIONS.some(ext => name.endsWith(ext));
- });
- if (validFiles.length > 0) {
- const dataTransfer = new DataTransfer();
- validFiles.forEach(f => dataTransfer.items.add(f));
- handleFileSelect(dataTransfer.files);
- }
- }
- });
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
- }
-
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput.click();
- });
- }
-
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', () => {
- resetState();
- });
- }
-
- if (processBtn) {
- processBtn.addEventListener('click', convertToPdf);
- }
+ if (processBtn) {
+ processBtn.addEventListener('click', convertToPdf);
+ }
});
diff --git a/src/js/logic/ocr-pdf-page.ts b/src/js/logic/ocr-pdf-page.ts
index bdadf7a..422ae66 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';
+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();
+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[] = [];
+function drawOcrTextLayer(
+ page: ReturnType,
+ ocrPage: OcrPage,
+ pageHeight: number,
+ primaryFont: PDFFont,
+ latinFont: PDFFont
+): void {
+ ocrPage.lines.forEach(function (line: OcrLine) {
+ const words = line.words;
- const wordElements = doc.querySelectorAll('.ocrx_word');
+ 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,
+ ''
+ );
- wordElements.forEach(function (wordEl) {
- const titleAttr = wordEl.getAttribute('title');
- const text = wordEl.textContent?.trim() || '';
+ if (!text.trim()) continue;
- if (!titleAttr || !text) return;
+ const hasNonLatin = /[^\u0000-\u007F]/.test(text);
+ const font = hasNonLatin ? primaryFont : latinFont;
- const bboxMatch = titleAttr.match(/bbox (\d+) (\d+) (\d+) (\d+)/);
- const confMatch = titleAttr.match(/x_wconf (\d+)/);
+ if (!font) {
+ console.warn('Font not available for text: "' + text + '"');
+ continue;
+ }
- 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,
- });
+ const transform = calculateWordTransform(
+ word,
+ line,
+ pageHeight,
+ (txt: string, size: number) => {
+ try {
+ return font.widthOfTextAtSize(txt, size);
+ } catch {
+ return 0;
+ }
}
- });
+ );
- return words;
+ 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`);
+ }
+ }
+ }
+ }
+ });
}
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 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 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;
+ return;
+ }
- 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,
+ });
}
- if (!pageState.file) {
- showAlert('No File', 'Please upload a PDF file first.');
- return;
- }
+ const arrayBuffer = await pageState.file.arrayBuffer();
+ const pdf = await getPDFDocument({ data: arrayBuffer }).promise;
+ const newPdfDoc = await PDFLibDocument.create();
- const langString = selectedLangs.join('+');
+ newPdfDoc.registerFontkit(fontkit);
- const toolOptions = document.getElementById('tool-options');
- const ocrProgress = document.getElementById('ocr-progress');
+ updateProgress('Loading fonts...', 0);
- if (toolOptions) toolOptions.classList.add('hidden');
- if (ocrProgress) ocrProgress.classList.remove('hidden');
+ 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;
+
+ let primaryFont;
+ let latinFont;
try {
- const worker = await Tesseract.createWorker(langString, 1, {
- logger: function (m: { status: string; progress: number }) {
- updateProgress(m.status, m.progress || 0);
- },
+ if (isIndicPlusLatin) {
+ const [scriptFontBytes, latinFontBytes] = await Promise.all([
+ getFontForLanguage(primaryLang),
+ getFontForLanguage('eng'),
+ ]);
+ primaryFont = await newPdfDoc.embedFont(scriptFontBytes, {
+ subset: false,
});
-
- await worker.setParameters({
- tessjs_create_hocr: '1',
- tessedit_pageseg_mode: Tesseract.PSM.AUTO,
+ latinFont = await newPdfDoc.embedFont(latinFontBytes, {
+ subset: false,
});
-
- if (whitelist) {
- await worker.setParameters({
- tessedit_char_whitelist: whitelist,
- });
- }
-
- 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;
-
- 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');
- });
-
- const pngImage = await newPdfDoc.embedPng(pngImageBytes);
- newPage.drawImage(pngImage, {
- x: 0,
- y: 0,
- width: viewport.width,
- height: viewport.height,
- });
-
- 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);
- }
- });
- }
-
- fullText += data.text + '\n\n';
- }
-
- await worker.terminate();
-
- pageState.searchablePdfBytes = await newPdfDoc.save();
-
- 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();
-
+ } else {
+ const fontBytes = await getFontForLanguage(primaryLang);
+ primaryFont = await newPdfDoc.embedFont(fontBytes, { subset: false });
+ latinFont = primaryFont;
+ }
} 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');
+ 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');
+ });
+
+ 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';
+ }
+
+ await worker.terminate();
+
+ pageState.searchablePdfBytes = await newPdfDoc.save();
+
+ 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;
+ const langList = document.getElementById('lang-list');
+ if (!langList) return;
- langList.innerHTML = '';
+ 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';
+ 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';
+ 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);
- });
+ 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');
+ 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();
+ populateLanguageList();
- if (backBtn) {
- backBtn.addEventListener('click', function () {
- window.location.href = import.meta.env.BASE_URL;
- });
- }
+ 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;
- }
- });
- }
-
- // 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)';
- }
- });
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', function (e) {
+ handleFileSelect((e.target as HTMLInputElement).files);
});
- // Process button
- if (processBtn) {
- processBtn.addEventListener('click', runOCR);
- }
+ dropZone.addEventListener('dragover', function (e) {
+ e.preventDefault();
+ dropZone.classList.add('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 });
+ dropZone.addEventListener('dragleave', function (e) {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
- 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 = '';
+ });
+ }
- // Download PDF
- if (downloadPdfBtn) {
- downloadPdfBtn.addEventListener('click', function () {
- if (pageState.searchablePdfBytes) {
- downloadFile(
- new Blob([new Uint8Array(pageState.searchablePdfBytes)], { type: 'application/pdf' }),
- 'searchable.pdf'
- );
- }
+ // 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;
+ }
+ });
+ }
+
+ // 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/logic/organize-pdf-page.ts b/src/js/logic/organize-pdf-page.ts
index 251e0d2..f0e5828 100644
--- a/src/js/logic/organize-pdf-page.ts
+++ b/src/js/logic/organize-pdf-page.ts
@@ -58,6 +58,74 @@ function initializePage() {
document.getElementById('back-to-tools')?.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
+
+ const applyOrderBtn = document.getElementById('apply-order-btn');
+ if (applyOrderBtn) applyOrderBtn.addEventListener('click', applyCustomOrder);
+}
+
+function applyCustomOrder() {
+ const orderInput = document.getElementById('page-order-input') as HTMLInputElement;
+ const grid = document.getElementById('page-grid');
+
+ if (!orderInput || !grid) return;
+
+ const orderString = orderInput.value;
+ if (!orderString) {
+ showAlert('Invalid Order', 'Please enter a page order.');
+ return;
+ }
+
+ const newOrder = orderString.split(',').map(s => parseInt(s.trim(), 10));
+
+ // Validation
+ const currentGridCount = grid.children.length;
+ const validNumbers = newOrder.every(n => !isNaN(n) && n > 0); // Basic check, will validate against available thumbnails
+ if (!validNumbers) {
+ showAlert('Invalid Page Numbers', `Please enter positive numbers.`);
+ return;
+ }
+
+ if (newOrder.length !== currentGridCount) {
+ showAlert('Incorrect Page Count', `The number of pages specified (${newOrder.length}) does not match the current number of pages in the document (${currentGridCount}). Please provide a complete ordering for all pages.`);
+ return;
+ }
+
+ const uniqueNumbers = new Set(newOrder);
+ if (uniqueNumbers.size !== newOrder.length) {
+ showAlert('Duplicate Page Numbers', 'Please ensure all page numbers in the order are unique.');
+ return;
+ }
+
+ const currentThumbnails = Array.from(grid.children) as HTMLElement[];
+ const reorderedThumbnails: HTMLElement[] = [];
+ const foundIndices = new Set();
+
+ for (const pageNum of newOrder) {
+ const originalIndexToFind = pageNum - 1; // pageNum is 1-based, originalPageIndex is 0-based
+ const foundThumbnail = currentThumbnails.find(
+ thumb => thumb.dataset.originalPageIndex === originalIndexToFind.toString()
+ );
+
+ if (foundThumbnail) {
+ reorderedThumbnails.push(foundThumbnail);
+ foundIndices.add(originalIndexToFind.toString());
+ }
+ }
+
+ const allOriginalIndicesPresent = currentThumbnails.every(thumb => foundIndices.has(thumb.dataset.originalPageIndex));
+
+ if (reorderedThumbnails.length !== currentGridCount || !allOriginalIndicesPresent) {
+ showAlert('Invalid Page Order', 'The specified page order is incomplete or contains invalid page numbers. Please ensure you provide a new position for every original page.');
+ return;
+ }
+
+ // Clear the grid and append the reordered thumbnails
+ grid.innerHTML = '';
+ reorderedThumbnails.forEach(thumb => grid.appendChild(thumb));
+
+ initializeSortable(); // Re-initialize sortable on the new order
+
+ showAlert('Success', 'Pages have been reordered.', 'success');
}
function handleFileUpload(e: Event) {
@@ -160,11 +228,13 @@ function attachEventListeners(element: HTMLElement) {
async function renderThumbnails() {
const grid = document.getElementById('page-grid');
const processBtn = document.getElementById('process-btn');
- if (!grid) return;
+ const advancedSettings = document.getElementById('advanced-settings');
+ if (!grid || !processBtn || !advancedSettings) return;
grid.innerHTML = '';
grid.classList.remove('hidden');
- processBtn?.classList.remove('hidden');
+ processBtn.classList.remove('hidden');
+ advancedSettings.classList.remove('hidden');
for (let i = 1; i <= organizeState.totalPages; i++) {
const page = await organizeState.pdfJsDoc.getPage(i);
@@ -289,6 +359,7 @@ function resetState() {
grid.classList.add('hidden');
}
document.getElementById('process-btn')?.classList.add('hidden');
+ document.getElementById('advanced-settings')?.classList.add('hidden');
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
}
diff --git a/src/js/logic/pdf-layers-page.ts b/src/js/logic/pdf-layers-page.ts
index 2975e7a..22ea415 100644
--- a/src/js/logic/pdf-layers-page.ts
+++ b/src/js/logic/pdf-layers-page.ts
@@ -1,21 +1,25 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
+import {
+ downloadFile,
+ readFileAsArrayBuffer,
+ formatBytes,
+ getPDFDocument,
+} from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
-
-const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
interface LayerData {
- number: number;
- xref: number;
- text: string;
- on: boolean;
- locked: boolean;
- depth: number;
- parentXref: number;
- displayOrder: number;
-};
+ number: number;
+ xref: number;
+ text: string;
+ on: boolean;
+ locked: boolean;
+ depth: number;
+ parentXref: number;
+ displayOrder: number;
+}
let currentFile: File | null = null;
let currentDoc: any = null;
@@ -23,151 +27,170 @@ const layersMap = new Map();
let nextDisplayOrder = 0;
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const processBtnContainer = document.getElementById('process-btn-container');
- const fileDisplayArea = document.getElementById('file-display-area');
- const layersContainer = document.getElementById('layers-container');
- const layersList = document.getElementById('layers-list');
- const backBtn = document.getElementById('back-to-tools');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const processBtnContainer = document.getElementById('process-btn-container');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const layersContainer = document.getElementById('layers-container');
+ const layersList = document.getElementById('layers-list');
+ const backBtn = document.getElementById('back-to-tools');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
+
+ const updateUI = async () => {
+ if (!fileDisplayArea || !processBtnContainer || !processBtn) return;
+
+ if (currentFile) {
+ fileDisplayArea.innerHTML = '';
+ 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 nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = currentFile.name;
+
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = `${formatBytes(currentFile.size)} โข Loading pages...`;
+
+ 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 = () => {
+ resetState();
+ };
+
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+
+ try {
+ const arrayBuffer = await readFileAsArrayBuffer(currentFile);
+ const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
+ metaSpan.textContent = `${formatBytes(currentFile.size)} โข ${pdfDoc.numPages} pages`;
+ } catch (error) {
+ console.error('Error loading PDF:', error);
+ metaSpan.textContent = `${formatBytes(currentFile.size)} โข Could not load page count`;
+ }
+
+ createIcons({ icons });
+ processBtnContainer.classList.remove('hidden');
+ (processBtn as HTMLButtonElement).disabled = false;
+ } else {
+ fileDisplayArea.innerHTML = '';
+ processBtnContainer.classList.add('hidden');
+ (processBtn as HTMLButtonElement).disabled = true;
}
+ };
- const updateUI = async () => {
- if (!fileDisplayArea || !processBtnContainer || !processBtn) return;
+ const resetState = () => {
+ currentFile = null;
+ currentDoc = null;
+ layersMap.clear();
+ nextDisplayOrder = 0;
- if (currentFile) {
- fileDisplayArea.innerHTML = '';
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ if (dropZone) dropZone.style.display = 'flex';
+ if (layersContainer) layersContainer.classList.add('hidden');
+ updateUI();
+ };
- const infoContainer = document.createElement('div');
- infoContainer.className = 'flex flex-col overflow-hidden';
+ const promptForInput = (
+ title: string,
+ message: string,
+ defaultValue: string = ''
+ ): Promise => {
+ return new Promise((resolve) => {
+ const modal = document.getElementById('input-modal');
+ const titleEl = document.getElementById('input-title');
+ const messageEl = document.getElementById('input-message');
+ const inputEl = document.getElementById(
+ 'input-value'
+ ) as HTMLInputElement;
+ const confirmBtn = document.getElementById('input-confirm');
+ const cancelBtn = document.getElementById('input-cancel');
- const nameSpan = document.createElement('div');
- nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
- nameSpan.textContent = currentFile.name;
+ if (
+ !modal ||
+ !titleEl ||
+ !messageEl ||
+ !inputEl ||
+ !confirmBtn ||
+ !cancelBtn
+ ) {
+ console.error('Input modal elements not found');
+ resolve(null);
+ return;
+ }
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = `${formatBytes(currentFile.size)} โข Loading pages...`;
+ titleEl.textContent = title;
+ messageEl.textContent = message;
+ inputEl.value = defaultValue;
- infoContainer.append(nameSpan, metaSpan);
+ const closeModal = () => {
+ modal.classList.add('hidden');
+ confirmBtn.onclick = null;
+ cancelBtn.onclick = null;
+ inputEl.onkeydown = null;
+ };
- const removeBtn = document.createElement('button');
- removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
- removeBtn.innerHTML = ' ';
- removeBtn.onclick = () => {
- resetState();
- };
+ const confirm = () => {
+ const val = inputEl.value.trim();
+ closeModal();
+ resolve(val);
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
+ const cancel = () => {
+ closeModal();
+ resolve(null);
+ };
- try {
- const arrayBuffer = await readFileAsArrayBuffer(currentFile);
- const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
- metaSpan.textContent = `${formatBytes(currentFile.size)} โข ${pdfDoc.numPages} pages`;
- } catch (error) {
- console.error('Error loading PDF:', error);
- metaSpan.textContent = `${formatBytes(currentFile.size)} โข Could not load page count`;
- }
+ confirmBtn.onclick = confirm;
+ cancelBtn.onclick = cancel;
- createIcons({ icons });
- processBtnContainer.classList.remove('hidden');
- (processBtn as HTMLButtonElement).disabled = false;
- } else {
- fileDisplayArea.innerHTML = '';
- processBtnContainer.classList.add('hidden');
- (processBtn as HTMLButtonElement).disabled = true;
- }
- };
+ inputEl.onkeydown = (e) => {
+ if (e.key === 'Enter') confirm();
+ if (e.key === 'Escape') cancel();
+ };
- const resetState = () => {
- currentFile = null;
- currentDoc = null;
- layersMap.clear();
- nextDisplayOrder = 0;
+ modal.classList.remove('hidden');
+ inputEl.focus();
+ });
+ };
- if (dropZone) dropZone.style.display = 'flex';
- if (layersContainer) layersContainer.classList.add('hidden');
- updateUI();
- };
+ const renderLayers = () => {
+ if (!layersList) return;
- const promptForInput = (title: string, message: string, defaultValue: string = ''): Promise => {
- return new Promise((resolve) => {
- const modal = document.getElementById('input-modal');
- const titleEl = document.getElementById('input-title');
- const messageEl = document.getElementById('input-message');
- const inputEl = document.getElementById('input-value') as HTMLInputElement;
- const confirmBtn = document.getElementById('input-confirm');
- const cancelBtn = document.getElementById('input-cancel');
+ const layersArray = Array.from(layersMap.values());
- if (!modal || !titleEl || !messageEl || !inputEl || !confirmBtn || !cancelBtn) {
- console.error('Input modal elements not found');
- resolve(null);
- return;
- }
-
- titleEl.textContent = title;
- messageEl.textContent = message;
- inputEl.value = defaultValue;
-
- const closeModal = () => {
- modal.classList.add('hidden');
- confirmBtn.onclick = null;
- cancelBtn.onclick = null;
- inputEl.onkeydown = null;
- };
-
- const confirm = () => {
- const val = inputEl.value.trim();
- closeModal();
- resolve(val);
- };
-
- const cancel = () => {
- closeModal();
- resolve(null);
- };
-
- confirmBtn.onclick = confirm;
- cancelBtn.onclick = cancel;
-
- inputEl.onkeydown = (e) => {
- if (e.key === 'Enter') confirm();
- if (e.key === 'Escape') cancel();
- };
-
- modal.classList.remove('hidden');
- inputEl.focus();
- });
- };
-
- const renderLayers = () => {
- if (!layersList) return;
-
- const layersArray = Array.from(layersMap.values());
-
- if (layersArray.length === 0) {
- layersList.innerHTML = `
+ if (layersArray.length === 0) {
+ layersList.innerHTML = `
This PDF has no layers (OCG).
Add a new layer to get started!
`;
- return;
- }
+ return;
+ }
- // Sort layers by displayOrder
- const sortedLayers = layersArray.sort((a, b) => a.displayOrder - b.displayOrder);
+ // Sort layers by displayOrder
+ const sortedLayers = layersArray.sort(
+ (a, b) => a.displayOrder - b.displayOrder
+ );
- layersList.innerHTML = sortedLayers.map((layer: LayerData) => `
+ layersList.innerHTML = sortedLayers
+ .map(
+ (layer: LayerData) => `
@@ -179,238 +202,261 @@ document.addEventListener('DOMContentLoaded', () => {
${!layer.locked ? `โ ` : ''}
- `).join('');
+ `
+ )
+ .join('');
- // Attach toggle handlers
- layersList.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
- checkbox.addEventListener('change', (e) => {
- const target = e.target as HTMLInputElement;
- const xref = parseInt(target.dataset.xref || '0');
- const isOn = target.checked;
+ // Attach toggle handlers
+ layersList
+ .querySelectorAll('input[type="checkbox"]')
+ .forEach((checkbox) => {
+ checkbox.addEventListener('change', (e) => {
+ const target = e.target as HTMLInputElement;
+ const xref = parseInt(target.dataset.xref || '0');
+ const isOn = target.checked;
- try {
- currentDoc.setLayerVisibility(xref, isOn);
- const layer = Array.from(layersMap.values()).find(l => l.xref === xref);
- if (layer) {
- layer.on = isOn;
- }
- } catch (err) {
- console.error('Failed to set layer visibility:', err);
- target.checked = !isOn;
- showAlert('Error', 'Failed to toggle layer visibility');
- }
- });
+ try {
+ currentDoc.setLayerVisibility(xref, isOn);
+ const layer = Array.from(layersMap.values()).find(
+ (l) => l.xref === xref
+ );
+ if (layer) {
+ layer.on = isOn;
+ }
+ } catch (err) {
+ console.error('Failed to set layer visibility:', err);
+ target.checked = !isOn;
+ showAlert('Error', 'Failed to toggle layer visibility');
+ }
});
+ });
- // Attach delete handlers
- layersList.querySelectorAll('.layer-delete').forEach((btn) => {
- btn.addEventListener('click', (e) => {
- const target = e.target as HTMLButtonElement;
- const xref = parseInt(target.dataset.xref || '0');
- const layer = Array.from(layersMap.values()).find(l => l.xref === xref);
+ // Attach delete handlers
+ layersList.querySelectorAll('.layer-delete').forEach((btn) => {
+ btn.addEventListener('click', (e) => {
+ const target = e.target as HTMLButtonElement;
+ const xref = parseInt(target.dataset.xref || '0');
+ const layer = Array.from(layersMap.values()).find(
+ (l) => l.xref === xref
+ );
- if (!layer) {
- showAlert('Error', 'Layer not found');
- return;
- }
-
- try {
- currentDoc.deleteOCG(layer.number);
- layersMap.delete(layer.number);
- renderLayers();
- } catch (err) {
- console.error('Failed to delete layer:', err);
- showAlert('Error', 'Failed to delete layer');
- }
- });
- });
-
- layersList.querySelectorAll('.layer-add-child').forEach((btn) => {
- btn.addEventListener('click', async (e) => {
- const target = e.target as HTMLButtonElement;
- const parentXref = parseInt(target.dataset.xref || '0');
- const parentLayer = Array.from(layersMap.values()).find(l => l.xref === parentXref);
-
- const childName = await promptForInput('Add Child Layer', `Enter name for child layer under "${parentLayer?.text || 'Layer'}":`);
-
- if (!childName || !childName.trim()) return;
-
- try {
- const childXref = currentDoc.addOCGWithParent(childName.trim(), parentXref);
- const parentDisplayOrder = parentLayer?.displayOrder || 0;
- layersMap.forEach((l) => {
- if (l.displayOrder > parentDisplayOrder) {
- l.displayOrder += 1;
- }
- });
-
- layersMap.set(childXref, {
- number: childXref,
- xref: childXref,
- text: childName.trim(),
- on: true,
- locked: false,
- depth: (parentLayer?.depth || 0) + 1,
- parentXref: parentXref,
- displayOrder: parentDisplayOrder + 1
- });
-
- renderLayers();
- } catch (err) {
- console.error('Failed to add child layer:', err);
- showAlert('Error', 'Failed to add child layer');
- }
- });
- });
- };
-
- const loadLayers = async () => {
- if (!currentFile) {
- showAlert('No File', 'Please select a PDF file.');
- return;
+ if (!layer) {
+ showAlert('Error', 'Layer not found');
+ return;
}
try {
- showLoader('Loading engine...');
- await pymupdf.load();
-
- showLoader(`Loading layers from ${currentFile.name}...`);
- currentDoc = await pymupdf.open(currentFile);
-
- showLoader('Reading layer configuration...');
- const existingLayers = currentDoc.getLayerConfig();
-
- // Reset and populate layers map
- layersMap.clear();
- nextDisplayOrder = 0;
-
- existingLayers.forEach((layer: any) => {
- layersMap.set(layer.number, {
- number: layer.number,
- xref: layer.xref ?? layer.number,
- text: layer.text,
- on: layer.on,
- locked: layer.locked,
- depth: layer.depth ?? 0,
- parentXref: layer.parentXref ?? 0,
- displayOrder: layer.displayOrder ?? nextDisplayOrder++
- });
- if ((layer.displayOrder ?? -1) >= nextDisplayOrder) {
- nextDisplayOrder = layer.displayOrder + 1;
- }
- });
-
- hideLoader();
-
- // Hide upload zone, show layers container
- if (dropZone) dropZone.style.display = 'none';
- if (processBtnContainer) processBtnContainer.classList.add('hidden');
- if (layersContainer) layersContainer.classList.remove('hidden');
-
- renderLayers();
- setupLayerHandlers();
-
- } catch (error: any) {
- hideLoader();
- showAlert('Error', error.message || 'Failed to load PDF layers');
- console.error('Layers error:', error);
+ currentDoc.deleteOCG(layer.number);
+ layersMap.delete(layer.number);
+ renderLayers();
+ } catch (err) {
+ console.error('Failed to delete layer:', err);
+ showAlert('Error', 'Failed to delete layer');
}
- };
+ });
+ });
- const setupLayerHandlers = () => {
- const addLayerBtn = document.getElementById('add-layer-btn');
- const newLayerInput = document.getElementById('new-layer-name') as HTMLInputElement;
- const saveLayersBtn = document.getElementById('save-layers-btn');
+ layersList.querySelectorAll('.layer-add-child').forEach((btn) => {
+ btn.addEventListener('click', async (e) => {
+ const target = e.target as HTMLButtonElement;
+ const parentXref = parseInt(target.dataset.xref || '0');
+ const parentLayer = Array.from(layersMap.values()).find(
+ (l) => l.xref === parentXref
+ );
- if (addLayerBtn && newLayerInput) {
- addLayerBtn.onclick = () => {
- const name = newLayerInput.value.trim();
- if (!name) {
- showAlert('Invalid Name', 'Please enter a layer name');
- return;
- }
+ const childName = await promptForInput(
+ 'Add Child Layer',
+ `Enter name for child layer under "${parentLayer?.text || 'Layer'}":`
+ );
- try {
- const xref = currentDoc.addOCG(name);
- newLayerInput.value = '';
+ if (!childName || !childName.trim()) return;
- const newDisplayOrder = nextDisplayOrder++;
- layersMap.set(xref, {
- number: xref,
- xref: xref,
- text: name,
- on: true,
- locked: false,
- depth: 0,
- parentXref: 0,
- displayOrder: newDisplayOrder
- });
-
- renderLayers();
- } catch (err: any) {
- showAlert('Error', 'Failed to add layer: ' + err.message);
- }
- };
- }
-
- if (saveLayersBtn) {
- saveLayersBtn.onclick = () => {
- try {
- showLoader('Saving PDF with layer changes...');
- const pdfBytes = currentDoc.save();
- const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' });
- const outName = currentFile!.name.replace(/\.pdf$/i, '') + '_layers.pdf';
- downloadFile(blob, outName);
- hideLoader();
- resetState();
- showAlert('Success', 'PDF with layer changes saved!', 'success');
- } catch (err: any) {
- hideLoader();
- showAlert('Error', 'Failed to save PDF: ' + err.message);
- }
- };
- }
- };
-
- const handleFileSelect = (files: FileList | null) => {
- if (files && files.length > 0) {
- const file = files[0];
- if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
- currentFile = file;
- updateUI();
- } else {
- showAlert('Invalid File', 'Please select a PDF file.');
+ try {
+ const childXref = currentDoc.addOCGWithParent(
+ childName.trim(),
+ parentXref
+ );
+ const parentDisplayOrder = parentLayer?.displayOrder || 0;
+ layersMap.forEach((l) => {
+ if (l.displayOrder > parentDisplayOrder) {
+ l.displayOrder += 1;
}
+ });
+
+ layersMap.set(childXref, {
+ number: childXref,
+ xref: childXref,
+ text: childName.trim(),
+ on: true,
+ locked: false,
+ depth: (parentLayer?.depth || 0) + 1,
+ parentXref: parentXref,
+ displayOrder: parentDisplayOrder + 1,
+ });
+
+ renderLayers();
+ } catch (err) {
+ console.error('Failed to add child layer:', err);
+ showAlert('Error', 'Failed to add child layer');
}
- };
+ });
+ });
+ };
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
-
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
-
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- handleFileSelect(e.dataTransfer?.files ?? null);
- });
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
+ const loadLayers = async () => {
+ if (!currentFile) {
+ showAlert('No File', 'Please select a PDF file.');
+ return;
}
- if (processBtn) {
- processBtn.addEventListener('click', loadLayers);
+ try {
+ showLoader('Loading engine...');
+ const pymupdf = await loadPyMuPDF();
+
+ showLoader(`Loading layers from ${currentFile.name}...`);
+ currentDoc = await pymupdf.open(currentFile);
+
+ showLoader('Reading layer configuration...');
+ const existingLayers = currentDoc.getLayerConfig();
+
+ // Reset and populate layers map
+ layersMap.clear();
+ nextDisplayOrder = 0;
+
+ existingLayers.forEach((layer: any) => {
+ layersMap.set(layer.number, {
+ number: layer.number,
+ xref: layer.xref ?? layer.number,
+ text: layer.text,
+ on: layer.on,
+ locked: layer.locked,
+ depth: layer.depth ?? 0,
+ parentXref: layer.parentXref ?? 0,
+ displayOrder: layer.displayOrder ?? nextDisplayOrder++,
+ });
+ if ((layer.displayOrder ?? -1) >= nextDisplayOrder) {
+ nextDisplayOrder = layer.displayOrder + 1;
+ }
+ });
+
+ hideLoader();
+
+ // Hide upload zone, show layers container
+ if (dropZone) dropZone.style.display = 'none';
+ if (processBtnContainer) processBtnContainer.classList.add('hidden');
+ if (layersContainer) layersContainer.classList.remove('hidden');
+
+ renderLayers();
+ setupLayerHandlers();
+ } catch (error: any) {
+ hideLoader();
+ showAlert('Error', error.message || 'Failed to load PDF layers');
+ console.error('Layers error:', error);
}
+ };
+
+ const setupLayerHandlers = () => {
+ const addLayerBtn = document.getElementById('add-layer-btn');
+ const newLayerInput = document.getElementById(
+ 'new-layer-name'
+ ) as HTMLInputElement;
+ const saveLayersBtn = document.getElementById('save-layers-btn');
+
+ if (addLayerBtn && newLayerInput) {
+ addLayerBtn.onclick = () => {
+ const name = newLayerInput.value.trim();
+ if (!name) {
+ showAlert('Invalid Name', 'Please enter a layer name');
+ return;
+ }
+
+ try {
+ const xref = currentDoc.addOCG(name);
+ newLayerInput.value = '';
+
+ const newDisplayOrder = nextDisplayOrder++;
+ layersMap.set(xref, {
+ number: xref,
+ xref: xref,
+ text: name,
+ on: true,
+ locked: false,
+ depth: 0,
+ parentXref: 0,
+ displayOrder: newDisplayOrder,
+ });
+
+ renderLayers();
+ } catch (err: any) {
+ showAlert('Error', 'Failed to add layer: ' + err.message);
+ }
+ };
+ }
+
+ if (saveLayersBtn) {
+ saveLayersBtn.onclick = () => {
+ try {
+ showLoader('Saving PDF with layer changes...');
+ const pdfBytes = currentDoc.save();
+ const blob = new Blob([new Uint8Array(pdfBytes)], {
+ type: 'application/pdf',
+ });
+ const outName =
+ currentFile!.name.replace(/\.pdf$/i, '') + '_layers.pdf';
+ downloadFile(blob, outName);
+ hideLoader();
+ resetState();
+ showAlert('Success', 'PDF with layer changes saved!', 'success');
+ } catch (err: any) {
+ hideLoader();
+ showAlert('Error', 'Failed to save PDF: ' + err.message);
+ }
+ };
+ }
+ };
+
+ const handleFileSelect = (files: FileList | null) => {
+ if (files && files.length > 0) {
+ const file = files[0];
+ if (
+ file.type === 'application/pdf' ||
+ file.name.toLowerCase().endsWith('.pdf')
+ ) {
+ currentFile = file;
+ updateUI();
+ } else {
+ showAlert('Invalid File', 'Please select a PDF file.');
+ }
+ }
+ };
+
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
+
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
+
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
+
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ handleFileSelect(e.dataTransfer?.files ?? null);
+ });
+
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+
+ if (processBtn) {
+ processBtn.addEventListener('click', loadLayers);
+ }
});
diff --git a/src/js/logic/pdf-to-csv-page.ts b/src/js/logic/pdf-to-csv-page.ts
index 3415fe2..ef94b8c 100644
--- a/src/js/logic/pdf-to-csv-page.ts
+++ b/src/js/logic/pdf-to-csv-page.ts
@@ -2,171 +2,186 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
-
-const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
let file: File | null = null;
const updateUI = () => {
- const fileDisplayArea = document.getElementById('file-display-area');
- const optionsPanel = document.getElementById('options-panel');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const optionsPanel = document.getElementById('options-panel');
- if (!fileDisplayArea || !optionsPanel) return;
+ if (!fileDisplayArea || !optionsPanel) return;
- fileDisplayArea.innerHTML = '';
+ fileDisplayArea.innerHTML = '';
- if (file) {
- optionsPanel.classList.remove('hidden');
+ if (file) {
+ optionsPanel.classList.remove('hidden');
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ 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 = file.name;
+ const nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = formatBytes(file.size);
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = formatBytes(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 = resetState;
+ const removeBtn = document.createElement('button');
+ removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = '
';
+ removeBtn.onclick = resetState;
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
- createIcons({ icons });
- } else {
- optionsPanel.classList.add('hidden');
- }
+ createIcons({ icons });
+ } else {
+ optionsPanel.classList.add('hidden');
+ }
};
const resetState = () => {
- file = null;
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- if (fileInput) fileInput.value = '';
- updateUI();
+ file = null;
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ if (fileInput) fileInput.value = '';
+ updateUI();
};
function tableToCsv(rows: (string | null)[][]): string {
- return rows.map(row =>
- row.map(cell => {
- const cellStr = cell ?? '';
- if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) {
- return `"${cellStr.replace(/"/g, '""')}"`;
- }
- return cellStr;
- }).join(',')
- ).join('\n');
+ return rows
+ .map((row) =>
+ row
+ .map((cell) => {
+ const cellStr = cell ?? '';
+ if (
+ cellStr.includes(',') ||
+ cellStr.includes('"') ||
+ cellStr.includes('\n')
+ ) {
+ return `"${cellStr.replace(/"/g, '""')}"`;
+ }
+ return cellStr;
+ })
+ .join(',')
+ )
+ .join('\n');
}
async function convert() {
- if (!file) {
- showAlert('No File', 'Please upload a PDF file first.');
- return;
+ if (!file) {
+ showAlert('No File', 'Please upload a PDF file first.');
+ return;
+ }
+
+ showLoader('Loading Engine...');
+
+ try {
+ const pymupdf = await loadPyMuPDF();
+ showLoader('Extracting tables...');
+
+ const doc = await pymupdf.open(file);
+ const pageCount = doc.pageCount;
+ const baseName = file.name.replace(/\.[^/.]+$/, '');
+
+ const allRows: (string | null)[][] = [];
+
+ for (let i = 0; i < pageCount; i++) {
+ showLoader(`Scanning page ${i + 1} of ${pageCount}...`);
+ const page = doc.getPage(i);
+ const tables = page.findTables();
+
+ tables.forEach((table) => {
+ allRows.push(...table.rows);
+ allRows.push([]);
+ });
}
- showLoader('Loading Engine...');
-
- try {
- await pymupdf.load();
- showLoader('Extracting tables...');
-
- const doc = await pymupdf.open(file);
- const pageCount = doc.pageCount;
- const baseName = file.name.replace(/\.[^/.]+$/, '');
-
- const allRows: (string | null)[][] = [];
-
- for (let i = 0; i < pageCount; i++) {
- showLoader(`Scanning page ${i + 1} of ${pageCount}...`);
- const page = doc.getPage(i);
- const tables = page.findTables();
-
- tables.forEach((table) => {
- allRows.push(...table.rows);
- allRows.push([]);
- });
- }
-
- if (allRows.length === 0) {
- showAlert('No Tables Found', 'No tables were detected in this PDF.');
- return;
- }
-
- const csvContent = tableToCsv(allRows.filter(row => row.length > 0));
- const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
- downloadFile(blob, `${baseName}.csv`);
- showAlert('Success', 'PDF converted to CSV successfully!', 'success', resetState);
- } catch (e) {
- console.error(e);
- const message = e instanceof Error ? e.message : 'Unknown error';
- showAlert('Error', `Failed to convert PDF to CSV. ${message}`);
- } finally {
- hideLoader();
+ if (allRows.length === 0) {
+ showAlert('No Tables Found', 'No tables were detected in this PDF.');
+ return;
}
+
+ const csvContent = tableToCsv(allRows.filter((row) => row.length > 0));
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
+ downloadFile(blob, `${baseName}.csv`);
+ showAlert(
+ 'Success',
+ 'PDF converted to CSV successfully!',
+ 'success',
+ resetState
+ );
+ } catch (e) {
+ console.error(e);
+ const message = e instanceof Error ? e.message : 'Unknown error';
+ showAlert('Error', `Failed to convert PDF to CSV. ${message}`);
+ } finally {
+ hideLoader();
+ }
}
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const backBtn = document.getElementById('back-to-tools');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const backBtn = document.getElementById('back-to-tools');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
+
+ const handleFileSelect = (newFiles: FileList | null) => {
+ if (!newFiles || newFiles.length === 0) return;
+ const validFile = Array.from(newFiles).find(
+ (f) => f.type === 'application/pdf'
+ );
+
+ if (!validFile) {
+ showAlert('Invalid File', 'Please upload a PDF file.');
+ return;
}
- const handleFileSelect = (newFiles: FileList | null) => {
- if (!newFiles || newFiles.length === 0) return;
- const validFile = Array.from(newFiles).find(f => f.type === 'application/pdf');
+ file = validFile;
+ updateUI();
+ };
- if (!validFile) {
- showAlert('Invalid File', 'Please upload a PDF file.');
- return;
- }
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
- file = validFile;
- updateUI();
- };
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ handleFileSelect(e.dataTransfer?.files ?? null);
+ });
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- handleFileSelect(e.dataTransfer?.files ?? null);
- });
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
- }
-
- if (processBtn) {
- processBtn.addEventListener('click', convert);
- }
+ if (processBtn) {
+ processBtn.addEventListener('click', convert);
+ }
});
diff --git a/src/js/logic/pdf-to-docx-page.ts b/src/js/logic/pdf-to-docx-page.ts
index 2d9e2b6..3850fbc 100644
--- a/src/js/logic/pdf-to-docx-page.ts
+++ b/src/js/logic/pdf-to-docx-page.ts
@@ -1,203 +1,216 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
+import {
+ downloadFile,
+ readFileAsArrayBuffer,
+ formatBytes,
+ getPDFDocument,
+} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
-
-const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const fileDisplayArea = document.getElementById('file-display-area');
- const convertOptions = document.getElementById('convert-options');
- const fileControls = document.getElementById('file-controls');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const backBtn = document.getElementById('back-to-tools');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const convertOptions = document.getElementById('convert-options');
+ const fileControls = document.getElementById('file-controls');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const backBtn = document.getElementById('back-to-tools');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
- }
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
- const updateUI = async () => {
- if (!fileDisplayArea || !convertOptions || !processBtn || !fileControls) return;
+ const updateUI = async () => {
+ if (!fileDisplayArea || !convertOptions || !processBtn || !fileControls)
+ return;
- if (state.files.length > 0) {
- fileDisplayArea.innerHTML = '';
+ if (state.files.length > 0) {
+ fileDisplayArea.innerHTML = '';
- for (let index = 0; index < state.files.length; index++) {
- const file = state.files[index];
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ for (let index = 0; index < state.files.length; index++) {
+ const file = state.files[index];
+ 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 = file.name;
+ const nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`;
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`;
- 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 = () => {
- state.files = state.files.filter((_: File, i: number) => i !== index);
- updateUI();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className =
+ 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = '
';
+ removeBtn.onclick = () => {
+ state.files = state.files.filter((_: File, i: number) => i !== index);
+ updateUI();
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
- try {
- const arrayBuffer = await readFileAsArrayBuffer(file);
- const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
- metaSpan.textContent = `${formatBytes(file.size)} โข ${pdfDoc.numPages} pages`;
- } catch (error) {
- metaSpan.textContent = `${formatBytes(file.size)} โข Could not load page count`;
- }
- }
-
- createIcons({ icons });
- fileControls.classList.remove('hidden');
- convertOptions.classList.remove('hidden');
- (processBtn as HTMLButtonElement).disabled = false;
- } else {
- fileDisplayArea.innerHTML = '';
- fileControls.classList.add('hidden');
- convertOptions.classList.add('hidden');
- (processBtn as HTMLButtonElement).disabled = true;
- }
- };
-
- const resetState = () => {
- state.files = [];
- state.pdfDoc = null;
- updateUI();
- };
-
- const convert = async () => {
try {
- if (state.files.length === 0) {
- showAlert('No Files', 'Please select at least one PDF file.');
- return;
- }
-
- showLoader('Loading PDF converter...');
- await pymupdf.load();
-
- if (state.files.length === 1) {
- const file = state.files[0];
- showLoader(`Converting ${file.name}...`);
-
- const docxBlob = await pymupdf.pdfToDocx(file);
- const outName = file.name.replace(/\.pdf$/i, '') + '.docx';
-
- downloadFile(docxBlob, outName);
- hideLoader();
-
- showAlert(
- 'Conversion Complete',
- `Successfully converted ${file.name} to DOCX.`,
- 'success',
- () => resetState()
- );
- } else {
- showLoader('Converting multiple PDFs...');
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
-
- for (let i = 0; i < state.files.length; i++) {
- const file = state.files[i];
- showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
-
- const docxBlob = await pymupdf.pdfToDocx(file);
- const baseName = file.name.replace(/\.pdf$/i, '');
- const arrayBuffer = await docxBlob.arrayBuffer();
- zip.file(`${baseName}.docx`, arrayBuffer);
- }
-
- showLoader('Creating ZIP archive...');
- const zipBlob = await zip.generateAsync({ type: 'blob' });
-
- downloadFile(zipBlob, 'converted-documents.zip');
- hideLoader();
-
- showAlert(
- 'Conversion Complete',
- `Successfully converted ${state.files.length} PDF(s) to DOCX.`,
- 'success',
- () => resetState()
- );
- }
- } catch (e: any) {
- hideLoader();
- showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
+ const arrayBuffer = await readFileAsArrayBuffer(file);
+ const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
+ metaSpan.textContent = `${formatBytes(file.size)} โข ${pdfDoc.numPages} pages`;
+ } catch (error) {
+ metaSpan.textContent = `${formatBytes(file.size)} โข Could not load page count`;
}
- };
+ }
- const handleFileSelect = (files: FileList | null) => {
- if (files && files.length > 0) {
- const pdfFiles = Array.from(files).filter(
- f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
- );
- state.files = [...state.files, ...pdfFiles];
- updateUI();
+ createIcons({ icons });
+ fileControls.classList.remove('hidden');
+ convertOptions.classList.remove('hidden');
+ (processBtn as HTMLButtonElement).disabled = false;
+ } else {
+ fileDisplayArea.innerHTML = '';
+ fileControls.classList.add('hidden');
+ convertOptions.classList.add('hidden');
+ (processBtn as HTMLButtonElement).disabled = true;
+ }
+ };
+
+ const resetState = () => {
+ state.files = [];
+ state.pdfDoc = null;
+ updateUI();
+ };
+
+ const convert = async () => {
+ try {
+ if (state.files.length === 0) {
+ showAlert('No Files', 'Please select at least one PDF file.');
+ return;
+ }
+
+ showLoader('Loading PDF converter...');
+ const pymupdf = await loadPyMuPDF();
+
+ if (state.files.length === 1) {
+ const file = state.files[0];
+ showLoader(`Converting ${file.name}...`);
+
+ const docxBlob = await pymupdf.pdfToDocx(file);
+ const outName = file.name.replace(/\.pdf$/i, '') + '.docx';
+
+ downloadFile(docxBlob, outName);
+ hideLoader();
+
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${file.name} to DOCX.`,
+ 'success',
+ () => resetState()
+ );
+ } else {
+ showLoader('Converting multiple PDFs...');
+ const JSZip = (await import('jszip')).default;
+ const zip = new JSZip();
+
+ for (let i = 0; i < state.files.length; i++) {
+ const file = state.files[i];
+ showLoader(
+ `Converting ${i + 1}/${state.files.length}: ${file.name}...`
+ );
+
+ const docxBlob = await pymupdf.pdfToDocx(file);
+ const baseName = file.name.replace(/\.pdf$/i, '');
+ const arrayBuffer = await docxBlob.arrayBuffer();
+ zip.file(`${baseName}.docx`, arrayBuffer);
}
- };
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
+ showLoader('Creating ZIP archive...');
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
+ downloadFile(zipBlob, 'converted-documents.zip');
+ hideLoader();
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- const files = e.dataTransfer?.files;
- if (files && files.length > 0) {
- handleFileSelect(files);
- }
- });
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${state.files.length} PDF(s) to DOCX.`,
+ 'success',
+ () => resetState()
+ );
+ }
+ } catch (e: any) {
+ hideLoader();
+ showAlert(
+ 'Error',
+ `An error occurred during conversion. Error: ${e.message}`
+ );
}
+ };
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput.click();
- });
+ const handleFileSelect = (files: FileList | null) => {
+ if (files && files.length > 0) {
+ const pdfFiles = Array.from(files).filter(
+ (f) =>
+ f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
+ );
+ state.files = [...state.files, ...pdfFiles];
+ updateUI();
}
+ };
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', () => {
- resetState();
- });
- }
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
- if (processBtn) {
- processBtn.addEventListener('click', convert);
- }
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
+
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
+
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ const files = e.dataTransfer?.files;
+ if (files && files.length > 0) {
+ handleFileSelect(files);
+ }
+ });
+
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
+
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', () => {
+ resetState();
+ });
+ }
+
+ if (processBtn) {
+ processBtn.addEventListener('click', convert);
+ }
});
diff --git a/src/js/logic/pdf-to-excel-page.ts b/src/js/logic/pdf-to-excel-page.ts
index 0d11648..33fa38b 100644
--- a/src/js/logic/pdf-to-excel-page.ts
+++ b/src/js/logic/pdf-to-excel-page.ts
@@ -1,182 +1,194 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
import * as XLSX from 'xlsx';
-
-const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
let file: File | null = null;
const updateUI = () => {
- const fileDisplayArea = document.getElementById('file-display-area');
- const optionsPanel = document.getElementById('options-panel');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const optionsPanel = document.getElementById('options-panel');
- if (!fileDisplayArea || !optionsPanel) return;
+ if (!fileDisplayArea || !optionsPanel) return;
- fileDisplayArea.innerHTML = '';
+ fileDisplayArea.innerHTML = '';
- if (file) {
- optionsPanel.classList.remove('hidden');
+ if (file) {
+ optionsPanel.classList.remove('hidden');
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ 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 = file.name;
+ const nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = formatBytes(file.size);
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = formatBytes(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 = resetState;
+ const removeBtn = document.createElement('button');
+ removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = '
';
+ removeBtn.onclick = resetState;
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
- createIcons({ icons });
- } else {
- optionsPanel.classList.add('hidden');
- }
+ createIcons({ icons });
+ } else {
+ optionsPanel.classList.add('hidden');
+ }
};
const resetState = () => {
- file = null;
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- if (fileInput) fileInput.value = '';
- updateUI();
+ file = null;
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ if (fileInput) fileInput.value = '';
+ updateUI();
};
async function convert() {
- if (!file) {
- showAlert('No File', 'Please upload a PDF file first.');
- return;
+ if (!file) {
+ showAlert('No File', 'Please upload a PDF file first.');
+ return;
+ }
+
+ showLoader('Loading Engine...');
+
+ try {
+ const pymupdf = await loadPyMuPDF();
+ showLoader('Extracting tables...');
+
+ const doc = await pymupdf.open(file);
+ const pageCount = doc.pageCount;
+ const baseName = file.name.replace(/\.[^/.]+$/, '');
+
+ interface TableData {
+ page: number;
+ rows: (string | null)[][];
}
- showLoader('Loading Engine...');
+ const allTables: TableData[] = [];
- try {
- await pymupdf.load();
- showLoader('Extracting tables...');
+ for (let i = 0; i < pageCount; i++) {
+ showLoader(`Scanning page ${i + 1} of ${pageCount}...`);
+ const page = doc.getPage(i);
+ const tables = page.findTables();
- const doc = await pymupdf.open(file);
- const pageCount = doc.pageCount;
- const baseName = file.name.replace(/\.[^/.]+$/, '');
-
- interface TableData {
- page: number;
- rows: (string | null)[][];
- }
-
- const allTables: TableData[] = [];
-
- for (let i = 0; i < pageCount; i++) {
- showLoader(`Scanning page ${i + 1} of ${pageCount}...`);
- const page = doc.getPage(i);
- const tables = page.findTables();
-
- tables.forEach((table) => {
- allTables.push({
- page: i + 1,
- rows: table.rows
- });
- });
- }
-
- if (allTables.length === 0) {
- showAlert('No Tables Found', 'No tables were detected in this PDF.');
- return;
- }
-
- showLoader('Creating Excel file...');
-
- const workbook = XLSX.utils.book_new();
-
- if (allTables.length === 1) {
- const worksheet = XLSX.utils.aoa_to_sheet(allTables[0].rows);
- XLSX.utils.book_append_sheet(workbook, worksheet, 'Table');
- } else {
- allTables.forEach((table, idx) => {
- const sheetName = `Table ${idx + 1} (Page ${table.page})`.substring(0, 31);
- const worksheet = XLSX.utils.aoa_to_sheet(table.rows);
- XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
- });
- }
-
- const xlsxData = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
- const blob = new Blob([xlsxData], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
- downloadFile(blob, `${baseName}.xlsx`);
- showAlert('Success', `Extracted ${allTables.length} table(s) to Excel!`, 'success', resetState);
- } catch (e) {
- console.error(e);
- const message = e instanceof Error ? e.message : 'Unknown error';
- showAlert('Error', `Failed to convert PDF to Excel. ${message}`);
- } finally {
- hideLoader();
+ tables.forEach((table) => {
+ allTables.push({
+ page: i + 1,
+ rows: table.rows,
+ });
+ });
}
+
+ if (allTables.length === 0) {
+ showAlert('No Tables Found', 'No tables were detected in this PDF.');
+ return;
+ }
+
+ showLoader('Creating Excel file...');
+
+ const workbook = XLSX.utils.book_new();
+
+ if (allTables.length === 1) {
+ const worksheet = XLSX.utils.aoa_to_sheet(allTables[0].rows);
+ XLSX.utils.book_append_sheet(workbook, worksheet, 'Table');
+ } else {
+ allTables.forEach((table, idx) => {
+ const sheetName = `Table ${idx + 1} (Page ${table.page})`.substring(
+ 0,
+ 31
+ );
+ const worksheet = XLSX.utils.aoa_to_sheet(table.rows);
+ XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
+ });
+ }
+
+ const xlsxData = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
+ const blob = new Blob([xlsxData], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
+ downloadFile(blob, `${baseName}.xlsx`);
+ showAlert(
+ 'Success',
+ `Extracted ${allTables.length} table(s) to Excel!`,
+ 'success',
+ resetState
+ );
+ } catch (e) {
+ console.error(e);
+ const message = e instanceof Error ? e.message : 'Unknown error';
+ showAlert('Error', `Failed to convert PDF to Excel. ${message}`);
+ } finally {
+ hideLoader();
+ }
}
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const backBtn = document.getElementById('back-to-tools');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const backBtn = document.getElementById('back-to-tools');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
+
+ const handleFileSelect = (newFiles: FileList | null) => {
+ if (!newFiles || newFiles.length === 0) return;
+ const validFile = Array.from(newFiles).find(
+ (f) => f.type === 'application/pdf'
+ );
+
+ if (!validFile) {
+ showAlert('Invalid File', 'Please upload a PDF file.');
+ return;
}
- const handleFileSelect = (newFiles: FileList | null) => {
- if (!newFiles || newFiles.length === 0) return;
- const validFile = Array.from(newFiles).find(f => f.type === 'application/pdf');
+ file = validFile;
+ updateUI();
+ };
- if (!validFile) {
- showAlert('Invalid File', 'Please upload a PDF file.');
- return;
- }
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
- file = validFile;
- updateUI();
- };
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ handleFileSelect(e.dataTransfer?.files ?? null);
+ });
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- handleFileSelect(e.dataTransfer?.files ?? null);
- });
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
- }
-
- if (processBtn) {
- processBtn.addEventListener('click', convert);
- }
+ if (processBtn) {
+ processBtn.addEventListener('click', convert);
+ }
});
diff --git a/src/js/logic/pdf-to-json.ts b/src/js/logic/pdf-to-json.ts
index babeaeb..3ada998 100644
--- a/src/js/logic/pdf-to-json.ts
+++ b/src/js/logic/pdf-to-json.ts
@@ -1,101 +1,133 @@
-import JSZip from 'jszip'
-import { downloadFile, formatBytes, readFileAsArrayBuffer } from '../utils/helpers';
+import JSZip from 'jszip';
+import {
+ downloadFile,
+ formatBytes,
+ readFileAsArrayBuffer,
+} from '../utils/helpers';
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
+import { isCpdfAvailable } from '../utils/cpdf-helper.js';
+import {
+ showWasmRequiredDialog,
+ WasmProvider,
+} from '../utils/wasm-provider.js';
-const worker = new Worker(import.meta.env.BASE_URL + 'workers/pdf-to-json.worker.js');
+const worker = new Worker(
+ import.meta.env.BASE_URL + 'workers/pdf-to-json.worker.js'
+);
-let selectedFiles: File[] = []
+let selectedFiles: File[] = [];
-const pdfFilesInput = document.getElementById('pdfFiles') as HTMLInputElement
-const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement
-const statusMessage = document.getElementById('status-message') as HTMLDivElement
-const fileListDiv = document.getElementById('fileList') as HTMLDivElement
-const backToToolsBtn = document.getElementById('back-to-tools') as HTMLButtonElement
+const pdfFilesInput = document.getElementById('pdfFiles') as HTMLInputElement;
+const convertBtn = document.getElementById('convertBtn') as HTMLButtonElement;
+const statusMessage = document.getElementById(
+ 'status-message'
+) as HTMLDivElement;
+const fileListDiv = document.getElementById('fileList') as HTMLDivElement;
+const backToToolsBtn = document.getElementById(
+ 'back-to-tools'
+) as HTMLButtonElement;
function showStatus(
message: string,
type: 'success' | 'error' | 'info' = 'info'
) {
- statusMessage.textContent = message
- statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${type === 'success'
- ? 'bg-green-900 text-green-200'
- : type === 'error'
- ? 'bg-red-900 text-red-200'
- : 'bg-blue-900 text-blue-200'
- }`
- statusMessage.classList.remove('hidden')
+ statusMessage.textContent = message;
+ statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${
+ type === 'success'
+ ? 'bg-green-900 text-green-200'
+ : type === 'error'
+ ? 'bg-red-900 text-red-200'
+ : 'bg-blue-900 text-blue-200'
+ }`;
+ statusMessage.classList.remove('hidden');
}
function hideStatus() {
- statusMessage.classList.add('hidden')
+ statusMessage.classList.add('hidden');
}
function updateFileList() {
- fileListDiv.innerHTML = ''
+ fileListDiv.innerHTML = '';
if (selectedFiles.length === 0) {
- fileListDiv.classList.add('hidden')
- return
+ fileListDiv.classList.add('hidden');
+ return;
}
- fileListDiv.classList.remove('hidden')
+ fileListDiv.classList.remove('hidden');
selectedFiles.forEach((file) => {
- const fileDiv = document.createElement('div')
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm mb-2'
+ const fileDiv = document.createElement('div');
+ fileDiv.className =
+ 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm mb-2';
- const nameSpan = document.createElement('span')
- nameSpan.className = 'truncate font-medium text-gray-200'
- nameSpan.textContent = file.name
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'truncate font-medium text-gray-200';
+ nameSpan.textContent = file.name;
- const sizeSpan = document.createElement('span')
- sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400'
- sizeSpan.textContent = formatBytes(file.size)
+ const sizeSpan = document.createElement('span');
+ sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400';
+ sizeSpan.textContent = formatBytes(file.size);
- fileDiv.append(nameSpan, sizeSpan)
- fileListDiv.appendChild(fileDiv)
- })
+ fileDiv.append(nameSpan, sizeSpan);
+ fileListDiv.appendChild(fileDiv);
+ });
}
pdfFilesInput.addEventListener('change', (e) => {
- const target = e.target as HTMLInputElement
+ const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
- selectedFiles = Array.from(target.files)
- convertBtn.disabled = selectedFiles.length === 0
- updateFileList()
+ selectedFiles = Array.from(target.files);
+ convertBtn.disabled = selectedFiles.length === 0;
+ updateFileList();
if (selectedFiles.length === 0) {
- showStatus('Please select at least 1 PDF file', 'info')
+ showStatus('Please select at least 1 PDF file', 'info');
} else {
- showStatus(`${selectedFiles.length} file(s) selected. Ready to convert!`, 'info')
+ showStatus(
+ `${selectedFiles.length} file(s) selected. Ready to convert!`,
+ 'info'
+ );
}
}
-})
+});
async function convertPDFsToJSON() {
if (selectedFiles.length === 0) {
- showStatus('Please select at least 1 PDF file', 'error')
- return
+ showStatus('Please select at least 1 PDF file', 'error');
+ return;
+ }
+
+ // Check if CPDF is configured
+ if (!isCpdfAvailable()) {
+ showWasmRequiredDialog('cpdf');
+ return;
}
try {
- convertBtn.disabled = true
- showStatus('Reading files (Main Thread)...', 'info')
+ convertBtn.disabled = true;
+ showStatus('Reading files (Main Thread)...', 'info');
const fileBuffers = await Promise.all(
- selectedFiles.map(file => readFileAsArrayBuffer(file))
- )
+ selectedFiles.map((file) => readFileAsArrayBuffer(file))
+ );
- showStatus('Converting PDFs to JSON..', 'info')
-
- worker.postMessage({
- command: 'convert',
- fileBuffers: fileBuffers,
- fileNames: selectedFiles.map(f => f.name)
- }, fileBuffers);
+ showStatus('Converting PDFs to JSON..', 'info');
+ worker.postMessage(
+ {
+ command: 'convert',
+ fileBuffers: fileBuffers,
+ fileNames: selectedFiles.map((f) => f.name),
+ cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js',
+ },
+ fileBuffers
+ );
} catch (error) {
- console.error('Error reading files:', error)
- showStatus(`โ Error reading files: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error')
- convertBtn.disabled = false
+ console.error('Error reading files:', error);
+ showStatus(
+ `โ Error reading files: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ 'error'
+ );
+ convertBtn.disabled = false;
}
}
@@ -103,38 +135,45 @@ worker.onmessage = async (e: MessageEvent) => {
convertBtn.disabled = false;
if (e.data.status === 'success') {
- const jsonFiles = e.data.jsonFiles as Array<{ name: string, data: ArrayBuffer }>;
+ const jsonFiles = e.data.jsonFiles as Array<{
+ name: string;
+ data: ArrayBuffer;
+ }>;
try {
- showStatus('Creating ZIP file...', 'info')
+ showStatus('Creating ZIP file...', 'info');
- const zip = new JSZip()
+ const zip = new JSZip();
jsonFiles.forEach(({ name, data }) => {
- const jsonName = name.replace(/\.pdf$/i, '.json')
- const uint8Array = new Uint8Array(data)
- zip.file(jsonName, uint8Array)
- })
+ const jsonName = name.replace(/\.pdf$/i, '.json');
+ const uint8Array = new Uint8Array(data);
+ zip.file(jsonName, uint8Array);
+ });
- const zipBlob = await zip.generateAsync({ type: 'blob' })
- downloadFile(zipBlob, 'pdfs-to-json.zip')
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, 'pdfs-to-json.zip');
- showStatus('โ
PDFs converted to JSON successfully! ZIP download started.', 'success')
+ showStatus(
+ 'โ
PDFs converted to JSON successfully! ZIP download started.',
+ 'success'
+ );
- selectedFiles = []
- pdfFilesInput.value = ''
- fileListDiv.innerHTML = ''
- fileListDiv.classList.add('hidden')
- convertBtn.disabled = true
+ selectedFiles = [];
+ pdfFilesInput.value = '';
+ fileListDiv.innerHTML = '';
+ fileListDiv.classList.add('hidden');
+ convertBtn.disabled = true;
setTimeout(() => {
- hideStatus()
- }, 3000)
-
+ hideStatus();
+ }, 3000);
} catch (error) {
- console.error('Error creating ZIP:', error)
- showStatus(`โ Error creating ZIP: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error')
+ console.error('Error creating ZIP:', error);
+ showStatus(
+ `โ Error creating ZIP: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ 'error'
+ );
}
-
} else if (e.data.status === 'error') {
const errorMessage = e.data.message || 'Unknown error occurred in worker.';
console.error('Worker Error:', errorMessage);
@@ -144,11 +183,11 @@ worker.onmessage = async (e: MessageEvent) => {
if (backToToolsBtn) {
backToToolsBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL
- })
+ window.location.href = import.meta.env.BASE_URL;
+ });
}
-convertBtn.addEventListener('click', convertPDFsToJSON)
+convertBtn.addEventListener('click', convertPDFsToJSON);
-showStatus('Select PDF files to get started', 'info')
-initializeGlobalShortcuts()
+showStatus('Select PDF files to get started', 'info');
+initializeGlobalShortcuts();
diff --git a/src/js/logic/pdf-to-markdown-page.ts b/src/js/logic/pdf-to-markdown-page.ts
index d35dc86..f99294b 100644
--- a/src/js/logic/pdf-to-markdown-page.ts
+++ b/src/js/logic/pdf-to-markdown-page.ts
@@ -1,206 +1,221 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
+import {
+ downloadFile,
+ readFileAsArrayBuffer,
+ formatBytes,
+ getPDFDocument,
+} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
-
-const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const fileDisplayArea = document.getElementById('file-display-area');
- const convertOptions = document.getElementById('convert-options');
- const fileControls = document.getElementById('file-controls');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const backBtn = document.getElementById('back-to-tools');
- const includeImagesCheckbox = document.getElementById('include-images') as HTMLInputElement;
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const convertOptions = document.getElementById('convert-options');
+ const fileControls = document.getElementById('file-controls');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const backBtn = document.getElementById('back-to-tools');
+ const includeImagesCheckbox = document.getElementById(
+ 'include-images'
+ ) as HTMLInputElement;
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
- }
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
- const updateUI = async () => {
- if (!fileDisplayArea || !convertOptions || !processBtn || !fileControls) return;
+ const updateUI = async () => {
+ if (!fileDisplayArea || !convertOptions || !processBtn || !fileControls)
+ return;
- if (state.files.length > 0) {
- fileDisplayArea.innerHTML = '';
+ if (state.files.length > 0) {
+ fileDisplayArea.innerHTML = '';
- for (let index = 0; index < state.files.length; index++) {
- const file = state.files[index];
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ for (let index = 0; index < state.files.length; index++) {
+ const file = state.files[index];
+ 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 = file.name;
+ const nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`;
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`;
- 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 = () => {
- state.files = state.files.filter((_: File, i: number) => i !== index);
- updateUI();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className =
+ 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = '
';
+ removeBtn.onclick = () => {
+ state.files = state.files.filter((_: File, i: number) => i !== index);
+ updateUI();
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
- try {
- const arrayBuffer = await readFileAsArrayBuffer(file);
- const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
- metaSpan.textContent = `${formatBytes(file.size)} โข ${pdfDoc.numPages} pages`;
- } catch (error) {
- metaSpan.textContent = `${formatBytes(file.size)} โข Could not load page count`;
- }
- }
-
- createIcons({ icons });
- fileControls.classList.remove('hidden');
- convertOptions.classList.remove('hidden');
- (processBtn as HTMLButtonElement).disabled = false;
- } else {
- fileDisplayArea.innerHTML = '';
- fileControls.classList.add('hidden');
- convertOptions.classList.add('hidden');
- (processBtn as HTMLButtonElement).disabled = true;
- }
- };
-
- const resetState = () => {
- state.files = [];
- state.pdfDoc = null;
- updateUI();
- };
-
- const convert = async () => {
try {
- if (state.files.length === 0) {
- showAlert('No Files', 'Please select at least one PDF file.');
- return;
- }
-
- showLoader('Loading PDF converter...');
- await pymupdf.load();
-
- const includeImages = includeImagesCheckbox?.checked ?? false;
-
- if (state.files.length === 1) {
- const file = state.files[0];
- showLoader(`Converting ${file.name}...`);
-
- const markdown = await pymupdf.pdfToMarkdown(file, { includeImages });
- const outName = file.name.replace(/\.pdf$/i, '') + '.md';
- const blob = new Blob([markdown], { type: 'text/markdown' });
-
- downloadFile(blob, outName);
- hideLoader();
-
- showAlert(
- 'Conversion Complete',
- `Successfully converted ${file.name} to Markdown.`,
- 'success',
- () => resetState()
- );
- } else {
- showLoader('Converting multiple PDFs...');
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
-
- for (let i = 0; i < state.files.length; i++) {
- const file = state.files[i];
- showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
-
- const markdown = await pymupdf.pdfToMarkdown(file, { includeImages });
- const baseName = file.name.replace(/\.pdf$/i, '');
- zip.file(`${baseName}.md`, markdown);
- }
-
- showLoader('Creating ZIP archive...');
- const zipBlob = await zip.generateAsync({ type: 'blob' });
-
- downloadFile(zipBlob, 'markdown-files.zip');
- hideLoader();
-
- showAlert(
- 'Conversion Complete',
- `Successfully converted ${state.files.length} PDF(s) to Markdown.`,
- 'success',
- () => resetState()
- );
- }
- } catch (e: any) {
- hideLoader();
- showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
+ const arrayBuffer = await readFileAsArrayBuffer(file);
+ const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
+ metaSpan.textContent = `${formatBytes(file.size)} โข ${pdfDoc.numPages} pages`;
+ } catch (error) {
+ metaSpan.textContent = `${formatBytes(file.size)} โข Could not load page count`;
}
- };
+ }
- const handleFileSelect = (files: FileList | null) => {
- if (files && files.length > 0) {
- const pdfFiles = Array.from(files).filter(
- f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
- );
- state.files = [...state.files, ...pdfFiles];
- updateUI();
+ createIcons({ icons });
+ fileControls.classList.remove('hidden');
+ convertOptions.classList.remove('hidden');
+ (processBtn as HTMLButtonElement).disabled = false;
+ } else {
+ fileDisplayArea.innerHTML = '';
+ fileControls.classList.add('hidden');
+ convertOptions.classList.add('hidden');
+ (processBtn as HTMLButtonElement).disabled = true;
+ }
+ };
+
+ const resetState = () => {
+ state.files = [];
+ state.pdfDoc = null;
+ updateUI();
+ };
+
+ const convert = async () => {
+ try {
+ if (state.files.length === 0) {
+ showAlert('No Files', 'Please select at least one PDF file.');
+ return;
+ }
+
+ showLoader('Loading PDF converter...');
+ const pymupdf = await loadPyMuPDF();
+
+ const includeImages = includeImagesCheckbox?.checked ?? false;
+
+ if (state.files.length === 1) {
+ const file = state.files[0];
+ showLoader(`Converting ${file.name}...`);
+
+ const markdown = await pymupdf.pdfToMarkdown(file, { includeImages });
+ const outName = file.name.replace(/\.pdf$/i, '') + '.md';
+ const blob = new Blob([markdown], { type: 'text/markdown' });
+
+ downloadFile(blob, outName);
+ hideLoader();
+
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${file.name} to Markdown.`,
+ 'success',
+ () => resetState()
+ );
+ } else {
+ showLoader('Converting multiple PDFs...');
+ const JSZip = (await import('jszip')).default;
+ const zip = new JSZip();
+
+ for (let i = 0; i < state.files.length; i++) {
+ const file = state.files[i];
+ showLoader(
+ `Converting ${i + 1}/${state.files.length}: ${file.name}...`
+ );
+
+ const markdown = await pymupdf.pdfToMarkdown(file, { includeImages });
+ const baseName = file.name.replace(/\.pdf$/i, '');
+ zip.file(`${baseName}.md`, markdown);
}
- };
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
+ showLoader('Creating ZIP archive...');
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
+ downloadFile(zipBlob, 'markdown-files.zip');
+ hideLoader();
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- const files = e.dataTransfer?.files;
- if (files && files.length > 0) {
- handleFileSelect(files);
- }
- });
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${state.files.length} PDF(s) to Markdown.`,
+ 'success',
+ () => resetState()
+ );
+ }
+ } catch (e: any) {
+ hideLoader();
+ showAlert(
+ 'Error',
+ `An error occurred during conversion. Error: ${e.message}`
+ );
}
+ };
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput.click();
- });
+ const handleFileSelect = (files: FileList | null) => {
+ if (files && files.length > 0) {
+ const pdfFiles = Array.from(files).filter(
+ (f) =>
+ f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
+ );
+ state.files = [...state.files, ...pdfFiles];
+ updateUI();
}
+ };
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', () => {
- resetState();
- });
- }
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
- if (processBtn) {
- processBtn.addEventListener('click', convert);
- }
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
+
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
+
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ const files = e.dataTransfer?.files;
+ if (files && files.length > 0) {
+ handleFileSelect(files);
+ }
+ });
+
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
+
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', () => {
+ resetState();
+ });
+ }
+
+ if (processBtn) {
+ processBtn.addEventListener('click', convert);
+ }
});
diff --git a/src/js/logic/pdf-to-pdfa-page.ts b/src/js/logic/pdf-to-pdfa-page.ts
index 5f392e4..0de4073 100644
--- a/src/js/logic/pdf-to-pdfa-page.ts
+++ b/src/js/logic/pdf-to-pdfa-page.ts
@@ -1,228 +1,270 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import {
- downloadFile,
- readFileAsArrayBuffer,
- formatBytes,
- getPDFDocument,
+ downloadFile,
+ readFileAsArrayBuffer,
+ formatBytes,
+ getPDFDocument,
} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
import { convertFileToPdfA, type PdfALevel } from '../utils/ghostscript-loader';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const fileDisplayArea = document.getElementById('file-display-area');
- const optionsContainer = document.getElementById('options-container');
- const fileControls = document.getElementById('file-controls');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const backBtn = document.getElementById('back-to-tools');
- const pdfaLevelSelect = document.getElementById('pdfa-level') as HTMLSelectElement;
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const optionsContainer = document.getElementById('options-container');
+ const fileControls = document.getElementById('file-controls');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const backBtn = document.getElementById('back-to-tools');
+ const pdfaLevelSelect = document.getElementById(
+ 'pdfa-level'
+ ) as HTMLSelectElement;
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
- }
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
- const updateUI = async () => {
- if (!fileDisplayArea || !optionsContainer || !processBtn || !fileControls) return;
+ const updateUI = async () => {
+ if (!fileDisplayArea || !optionsContainer || !processBtn || !fileControls)
+ return;
- if (state.files.length > 0) {
- fileDisplayArea.innerHTML = '';
+ if (state.files.length > 0) {
+ fileDisplayArea.innerHTML = '';
- for (let index = 0; index < state.files.length; index++) {
- const file = state.files[index];
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ for (let index = 0; index < state.files.length; index++) {
+ const file = state.files[index];
+ 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 = file.name;
+ const nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`;
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`;
- 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 = () => {
- state.files = state.files.filter((_, i) => i !== index);
- updateUI();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className =
+ 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = '
';
+ removeBtn.onclick = () => {
+ state.files = state.files.filter((_, i) => i !== index);
+ updateUI();
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
-
- try {
- const arrayBuffer = await readFileAsArrayBuffer(file);
- const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
- metaSpan.textContent = `${formatBytes(file.size)} โข ${pdfDoc.numPages} pages`;
- } catch (error) {
- console.error('Error loading PDF:', error);
- metaSpan.textContent = `${formatBytes(file.size)} โข Could not load page count`;
- }
- }
-
- createIcons({ icons });
- fileControls.classList.remove('hidden');
- optionsContainer.classList.remove('hidden');
- (processBtn as HTMLButtonElement).disabled = false;
- } else {
- fileDisplayArea.innerHTML = '';
- fileControls.classList.add('hidden');
- optionsContainer.classList.add('hidden');
- (processBtn as HTMLButtonElement).disabled = true;
- }
- };
-
- const resetState = () => {
- state.files = [];
- state.pdfDoc = null;
-
- if (pdfaLevelSelect) pdfaLevelSelect.value = 'PDF/A-2b';
-
- updateUI();
- };
-
- const convertToPdfA = async () => {
- const level = pdfaLevelSelect.value as PdfALevel;
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
try {
- if (state.files.length === 0) {
- showAlert('No Files', 'Please select at least one PDF file.');
- hideLoader();
- return;
- }
+ const arrayBuffer = await readFileAsArrayBuffer(file);
+ const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
+ metaSpan.textContent = `${formatBytes(file.size)} โข ${pdfDoc.numPages} pages`;
+ } catch (error) {
+ console.error('Error loading PDF:', error);
+ metaSpan.textContent = `${formatBytes(file.size)} โข Could not load page count`;
+ }
+ }
- if (state.files.length === 1) {
- const originalFile = state.files[0];
+ createIcons({ icons });
+ fileControls.classList.remove('hidden');
+ optionsContainer.classList.remove('hidden');
+ (processBtn as HTMLButtonElement).disabled = false;
+ } else {
+ fileDisplayArea.innerHTML = '';
+ fileControls.classList.add('hidden');
+ optionsContainer.classList.add('hidden');
+ (processBtn as HTMLButtonElement).disabled = true;
+ }
+ };
- showLoader('Initializing Ghostscript...');
+ const resetState = () => {
+ state.files = [];
+ state.pdfDoc = null;
- const convertedBlob = await convertFileToPdfA(
- originalFile,
- level,
- (msg) => showLoader(msg)
- );
+ if (pdfaLevelSelect) pdfaLevelSelect.value = 'PDF/A-2b';
- const fileName = originalFile.name.replace(/\.pdf$/i, '') + '_pdfa.pdf';
+ updateUI();
+ };
- downloadFile(convertedBlob, fileName);
+ const convertToPdfA = async () => {
+ const level = pdfaLevelSelect.value as PdfALevel;
- hideLoader();
+ try {
+ if (state.files.length === 0) {
+ showAlert('No Files', 'Please select at least one PDF file.');
+ hideLoader();
+ return;
+ }
- showAlert(
- 'Conversion Complete',
- `Successfully converted ${originalFile.name} to ${level}.`,
- 'success',
- () => resetState()
- );
- } else {
- showLoader('Converting multiple PDFs to PDF/A...');
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
+ if (state.files.length === 1) {
+ const originalFile = state.files[0];
+ const preFlattenCheckbox = document.getElementById(
+ 'pre-flatten'
+ ) as HTMLInputElement;
+ const shouldPreFlatten = preFlattenCheckbox?.checked || false;
- for (let i = 0; i < state.files.length; i++) {
- const file = state.files[i];
- showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
+ let fileToConvert = originalFile;
- const convertedBlob = await convertFileToPdfA(
- file,
- level,
- (msg) => showLoader(msg)
- );
-
- const baseName = file.name.replace(/\.pdf$/i, '');
- const blobBuffer = await convertedBlob.arrayBuffer();
- zip.file(`${baseName}_pdfa.pdf`, blobBuffer);
- }
-
- const zipBlob = await zip.generateAsync({ type: 'blob' });
-
- downloadFile(zipBlob, 'pdfa-converted.zip');
-
- hideLoader();
-
- showAlert(
- 'Conversion Complete',
- `Successfully converted ${state.files.length} PDF(s) to ${level}.`,
- 'success',
- () => resetState()
- );
- }
- } catch (e: any) {
+ // Pre-flatten using PyMuPDF rasterization if checkbox is checked
+ if (shouldPreFlatten) {
+ if (!isPyMuPDFAvailable()) {
+ showWasmRequiredDialog('pymupdf');
hideLoader();
- showAlert(
- 'Error',
- `An error occurred during conversion. Error: ${e.message}`
- );
- }
- };
+ return;
+ }
- const handleFileSelect = (files: FileList | null) => {
- if (files && files.length > 0) {
- state.files = [...state.files, ...Array.from(files)];
- updateUI();
- }
- };
+ showLoader('Pre-flattening PDF...');
+ const pymupdf = await loadPyMuPDF();
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
-
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
-
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', (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(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
- if (pdfFiles.length > 0) {
- const dataTransfer = new DataTransfer();
- pdfFiles.forEach(f => dataTransfer.items.add(f));
- handleFileSelect(dataTransfer.files);
- }
+ // Rasterize PDF to images and back to PDF (300 DPI for quality)
+ const flattenedBlob = await (pymupdf as any).rasterizePdf(
+ originalFile,
+ {
+ dpi: 300,
+ format: 'png',
}
- });
+ );
- // Clear value on click to allow re-selecting the same file
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
- }
+ fileToConvert = new File([flattenedBlob], originalFile.name, {
+ type: 'application/pdf',
+ });
+ }
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput.click();
- });
- }
+ showLoader('Initializing Ghostscript...');
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', () => {
- resetState();
- });
- }
+ const convertedBlob = await convertFileToPdfA(
+ fileToConvert,
+ level,
+ (msg) => showLoader(msg)
+ );
- if (processBtn) {
- processBtn.addEventListener('click', convertToPdfA);
+ const fileName = originalFile.name.replace(/\.pdf$/i, '') + '_pdfa.pdf';
+
+ downloadFile(convertedBlob, fileName);
+
+ hideLoader();
+
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${originalFile.name} to ${level}.`,
+ 'success',
+ () => resetState()
+ );
+ } else {
+ showLoader('Converting multiple PDFs to PDF/A...');
+ const JSZip = (await import('jszip')).default;
+ const zip = new JSZip();
+
+ for (let i = 0; i < state.files.length; i++) {
+ const file = state.files[i];
+ showLoader(
+ `Converting ${i + 1}/${state.files.length}: ${file.name}...`
+ );
+
+ const convertedBlob = await convertFileToPdfA(file, level, (msg) =>
+ showLoader(msg)
+ );
+
+ const baseName = file.name.replace(/\.pdf$/i, '');
+ const blobBuffer = await convertedBlob.arrayBuffer();
+ zip.file(`${baseName}_pdfa.pdf`, blobBuffer);
+ }
+
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+
+ downloadFile(zipBlob, 'pdfa-converted.zip');
+
+ hideLoader();
+
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${state.files.length} PDF(s) to ${level}.`,
+ 'success',
+ () => resetState()
+ );
+ }
+ } catch (e: any) {
+ hideLoader();
+ showAlert(
+ 'Error',
+ `An error occurred during conversion. Error: ${e.message}`
+ );
}
+ };
+
+ const handleFileSelect = (files: FileList | null) => {
+ if (files && files.length > 0) {
+ state.files = [...state.files, ...Array.from(files)];
+ updateUI();
+ }
+ };
+
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
+
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
+
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
+
+ dropZone.addEventListener('drop', (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(
+ (f) =>
+ f.type === 'application/pdf' ||
+ f.name.toLowerCase().endsWith('.pdf')
+ );
+ if (pdfFiles.length > 0) {
+ const dataTransfer = new DataTransfer();
+ pdfFiles.forEach((f) => dataTransfer.items.add(f));
+ handleFileSelect(dataTransfer.files);
+ }
+ }
+ });
+
+ // Clear value on click to allow re-selecting the same file
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
+
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', () => {
+ resetState();
+ });
+ }
+
+ if (processBtn) {
+ processBtn.addEventListener('click', convertToPdfA);
+ }
});
diff --git a/src/js/logic/pdf-to-svg-page.ts b/src/js/logic/pdf-to-svg-page.ts
index 6813e24..6bae490 100644
--- a/src/js/logic/pdf-to-svg-page.ts
+++ b/src/js/logic/pdf-to-svg-page.ts
@@ -2,201 +2,237 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
import JSZip from 'jszip';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
-const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
+let pymupdf: any = null;
let files: File[] = [];
const updateUI = () => {
- const fileDisplayArea = document.getElementById('file-display-area');
- const optionsPanel = document.getElementById('options-panel');
- const fileControls = document.getElementById('file-controls');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const optionsPanel = document.getElementById('options-panel');
+ const fileControls = document.getElementById('file-controls');
- if (!fileDisplayArea || !optionsPanel) return;
+ if (!fileDisplayArea || !optionsPanel) return;
- fileDisplayArea.innerHTML = '';
+ fileDisplayArea.innerHTML = '';
- if (files.length > 0) {
- optionsPanel.classList.remove('hidden');
- if (fileControls) fileControls.classList.remove('hidden');
+ if (files.length > 0) {
+ optionsPanel.classList.remove('hidden');
+ if (fileControls) fileControls.classList.remove('hidden');
- files.forEach((file, index) => {
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ files.forEach((file, index) => {
+ 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 = file.name;
+ const nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = formatBytes(file.size);
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = formatBytes(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 = () => {
- files = files.filter((_, i) => i !== index);
- updateUI();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className =
+ 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = '
';
+ removeBtn.onclick = () => {
+ files = files.filter((_, i) => i !== index);
+ updateUI();
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
- });
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ });
- createIcons({ icons });
- } else {
- optionsPanel.classList.add('hidden');
- if (fileControls) fileControls.classList.add('hidden');
- }
+ createIcons({ icons });
+ } else {
+ optionsPanel.classList.add('hidden');
+ if (fileControls) fileControls.classList.add('hidden');
+ }
};
const resetState = () => {
- files = [];
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- if (fileInput) fileInput.value = '';
- updateUI();
+ files = [];
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ if (fileInput) fileInput.value = '';
+ updateUI();
};
async function convert() {
- if (files.length === 0) {
- showAlert('No Files', 'Please upload at least one PDF file.');
- return;
+ if (files.length === 0) {
+ showAlert('No Files', 'Please upload at least one PDF file.');
+ return;
+ }
+
+ // Check if PyMuPDF is configured
+ if (!isPyMuPDFAvailable()) {
+ showWasmRequiredDialog('pymupdf');
+ return;
+ }
+
+ showLoader('Loading Engine...');
+
+ try {
+ // Load PyMuPDF dynamically if not already loaded
+ if (!pymupdf) {
+ pymupdf = await loadPyMuPDF();
}
- showLoader('Loading Engine...');
+ const isSingleFile = files.length === 1;
- try {
- await pymupdf.load();
+ if (isSingleFile) {
+ const doc = await pymupdf.open(files[0]);
+ const pageCount = doc.pageCount;
+ const baseName = files[0].name.replace(/\.[^/.]+$/, '');
- const isSingleFile = files.length === 1;
-
- if (isSingleFile) {
- const doc = await pymupdf.open(files[0]);
- const pageCount = doc.pageCount;
- const baseName = files[0].name.replace(/\.[^/.]+$/, '');
-
- if (pageCount === 1) {
- showLoader('Converting to SVG...');
- const page = doc.getPage(0);
- const svgContent = page.toSvg();
- const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' });
- downloadFile(svgBlob, `${baseName}.svg`);
- showAlert('Success', 'PDF converted to SVG successfully!', 'success', () => resetState());
- } else {
- const zip = new JSZip();
- for (let i = 0; i < pageCount; i++) {
- showLoader(`Converting page ${i + 1} of ${pageCount}...`);
- const page = doc.getPage(i);
- const svgContent = page.toSvg();
- zip.file(`page_${i + 1}.svg`, svgContent);
- }
- showLoader('Creating ZIP file...');
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- downloadFile(zipBlob, `${baseName}_svg.zip`);
- showAlert('Success', `Converted ${pageCount} pages to SVG!`, 'success', () => resetState());
- }
- } else {
- const zip = new JSZip();
- let totalPages = 0;
-
- for (let f = 0; f < files.length; f++) {
- const file = files[f];
- showLoader(`Processing file ${f + 1} of ${files.length}...`);
- const doc = await pymupdf.open(file);
- const pageCount = doc.pageCount;
- const baseName = file.name.replace(/\.[^/.]+$/, '');
-
- for (let i = 0; i < pageCount; i++) {
- showLoader(`File ${f + 1}/${files.length}: Page ${i + 1}/${pageCount}`);
- const page = doc.getPage(i);
- const svgContent = page.toSvg();
- const fileName = pageCount === 1 ? `${baseName}.svg` : `${baseName}_page_${i + 1}.svg`;
- zip.file(fileName, svgContent);
- totalPages++;
- }
- }
-
- showLoader('Creating ZIP file...');
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- downloadFile(zipBlob, 'pdf_to_svg.zip');
- showAlert('Success', `Converted ${files.length} files (${totalPages} pages) to SVG!`, 'success', () => resetState());
+ if (pageCount === 1) {
+ showLoader('Converting to SVG...');
+ const page = doc.getPage(0);
+ const svgContent = page.toSvg();
+ const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' });
+ downloadFile(svgBlob, `${baseName}.svg`);
+ showAlert(
+ 'Success',
+ 'PDF converted to SVG successfully!',
+ 'success',
+ () => resetState()
+ );
+ } else {
+ const zip = new JSZip();
+ for (let i = 0; i < pageCount; i++) {
+ showLoader(`Converting page ${i + 1} of ${pageCount}...`);
+ const page = doc.getPage(i);
+ const svgContent = page.toSvg();
+ zip.file(`page_${i + 1}.svg`, svgContent);
}
- } catch (e) {
- console.error(e);
- const message = e instanceof Error ? e.message : 'Unknown error';
- showAlert('Error', `Failed to convert PDF to SVG. ${message}`);
- } finally {
- hideLoader();
+ showLoader('Creating ZIP file...');
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, `${baseName}_svg.zip`);
+ showAlert(
+ 'Success',
+ `Converted ${pageCount} pages to SVG!`,
+ 'success',
+ () => resetState()
+ );
+ }
+ } else {
+ const zip = new JSZip();
+ let totalPages = 0;
+
+ for (let f = 0; f < files.length; f++) {
+ const file = files[f];
+ showLoader(`Processing file ${f + 1} of ${files.length}...`);
+ const doc = await pymupdf.open(file);
+ const pageCount = doc.pageCount;
+ const baseName = file.name.replace(/\.[^/.]+$/, '');
+
+ for (let i = 0; i < pageCount; i++) {
+ showLoader(
+ `File ${f + 1}/${files.length}: Page ${i + 1}/${pageCount}`
+ );
+ const page = doc.getPage(i);
+ const svgContent = page.toSvg();
+ const fileName =
+ pageCount === 1
+ ? `${baseName}.svg`
+ : `${baseName}_page_${i + 1}.svg`;
+ zip.file(fileName, svgContent);
+ totalPages++;
+ }
+ }
+
+ showLoader('Creating ZIP file...');
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, 'pdf_to_svg.zip');
+ showAlert(
+ 'Success',
+ `Converted ${files.length} files (${totalPages} pages) to SVG!`,
+ 'success',
+ () => resetState()
+ );
}
+ } catch (e) {
+ console.error(e);
+ const message = e instanceof Error ? e.message : 'Unknown error';
+ showAlert('Error', `Failed to convert PDF to SVG. ${message}`);
+ } finally {
+ hideLoader();
+ }
}
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const backBtn = document.getElementById('back-to-tools');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const backBtn = document.getElementById('back-to-tools');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
+
+ const handleFileSelect = (newFiles: FileList | null, replace = false) => {
+ if (!newFiles || newFiles.length === 0) return;
+ const validFiles = Array.from(newFiles).filter(
+ (file) => file.type === 'application/pdf'
+ );
+
+ if (validFiles.length === 0) {
+ showAlert('Invalid Files', 'Please upload PDF files.');
+ return;
}
- const handleFileSelect = (newFiles: FileList | null, replace = false) => {
- if (!newFiles || newFiles.length === 0) return;
- const validFiles = Array.from(newFiles).filter(
- (file) => file.type === 'application/pdf'
- );
-
- if (validFiles.length === 0) {
- showAlert('Invalid Files', 'Please upload PDF files.');
- return;
- }
-
- if (replace) {
- files = validFiles;
- } else {
- files = [...files, ...validFiles];
- }
- updateUI();
- };
-
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files, files.length === 0);
- });
-
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
-
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- handleFileSelect(e.dataTransfer?.files ?? null, files.length === 0);
- });
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
+ if (replace) {
+ files = validFiles;
+ } else {
+ files = [...files, ...validFiles];
}
+ updateUI();
+ };
- if (addMoreBtn) addMoreBtn.addEventListener('click', () => fileInput?.click());
- if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState);
- if (processBtn) processBtn.addEventListener('click', convert);
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect(
+ (e.target as HTMLInputElement).files,
+ files.length === 0
+ );
+ });
+
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
+
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
+
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ handleFileSelect(e.dataTransfer?.files ?? null, files.length === 0);
+ });
+
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+
+ if (addMoreBtn)
+ addMoreBtn.addEventListener('click', () => fileInput?.click());
+ if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState);
+ if (processBtn) processBtn.addEventListener('click', convert);
});
diff --git a/src/js/logic/pdf-to-text-page.ts b/src/js/logic/pdf-to-text-page.ts
index 1df7fba..efa00ce 100644
--- a/src/js/logic/pdf-to-text-page.ts
+++ b/src/js/logic/pdf-to-text-page.ts
@@ -1,212 +1,233 @@
import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
let files: File[] = [];
-let pymupdf: PyMuPDF | null = null;
+let pymupdf: any = null;
if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', initializePage);
+ document.addEventListener('DOMContentLoaded', initializePage);
} else {
- initializePage();
+ initializePage();
}
function initializePage() {
- createIcons({ icons });
+ createIcons({ icons });
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const processBtn = document.getElementById('process-btn') as HTMLButtonElement;
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const processBtn = document.getElementById(
+ 'process-btn'
+ ) as HTMLButtonElement;
- if (fileInput) {
- fileInput.addEventListener('change', handleFileUpload);
- }
+ if (fileInput) {
+ fileInput.addEventListener('change', handleFileUpload);
+ }
- if (dropZone) {
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-600');
- });
-
- dropZone.addEventListener('dragleave', () => {
- dropZone.classList.remove('bg-gray-600');
- });
-
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-600');
- const droppedFiles = e.dataTransfer?.files;
- if (droppedFiles && droppedFiles.length > 0) {
- handleFiles(droppedFiles);
- }
- });
-
- fileInput?.addEventListener('click', () => {
- if (fileInput) fileInput.value = '';
- });
- }
-
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput?.click();
- });
- }
-
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', () => {
- files = [];
- updateUI();
- });
- }
-
- if (processBtn) {
- processBtn.addEventListener('click', extractText);
- }
-
- document.getElementById('back-to-tools')?.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
+ if (dropZone) {
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-600');
});
+
+ dropZone.addEventListener('dragleave', () => {
+ dropZone.classList.remove('bg-gray-600');
+ });
+
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-600');
+ const droppedFiles = e.dataTransfer?.files;
+ if (droppedFiles && droppedFiles.length > 0) {
+ handleFiles(droppedFiles);
+ }
+ });
+
+ fileInput?.addEventListener('click', () => {
+ if (fileInput) fileInput.value = '';
+ });
+ }
+
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput?.click();
+ });
+ }
+
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', () => {
+ files = [];
+ updateUI();
+ });
+ }
+
+ if (processBtn) {
+ processBtn.addEventListener('click', extractText);
+ }
+
+ document.getElementById('back-to-tools')?.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
}
function handleFileUpload(e: Event) {
- const input = e.target as HTMLInputElement;
- if (input.files && input.files.length > 0) {
- handleFiles(input.files);
- }
+ const input = e.target as HTMLInputElement;
+ if (input.files && input.files.length > 0) {
+ handleFiles(input.files);
+ }
}
function handleFiles(newFiles: FileList) {
- const validFiles = Array.from(newFiles).filter(file =>
- file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')
+ const validFiles = Array.from(newFiles).filter(
+ (file) =>
+ file.type === 'application/pdf' ||
+ file.name.toLowerCase().endsWith('.pdf')
+ );
+
+ if (validFiles.length < newFiles.length) {
+ showAlert(
+ 'Invalid Files',
+ 'Some files were skipped. Only PDF files are allowed.'
);
+ }
- if (validFiles.length < newFiles.length) {
- showAlert('Invalid Files', 'Some files were skipped. Only PDF files are allowed.');
- }
-
- if (validFiles.length > 0) {
- files = [...files, ...validFiles];
- updateUI();
- }
+ if (validFiles.length > 0) {
+ files = [...files, ...validFiles];
+ updateUI();
+ }
}
const resetState = () => {
- files = [];
- updateUI();
+ files = [];
+ updateUI();
};
function updateUI() {
- const fileDisplayArea = document.getElementById('file-display-area');
- const fileControls = document.getElementById('file-controls');
- const extractOptions = document.getElementById('extract-options');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const fileControls = document.getElementById('file-controls');
+ const extractOptions = document.getElementById('extract-options');
- if (!fileDisplayArea || !fileControls || !extractOptions) return;
+ if (!fileDisplayArea || !fileControls || !extractOptions) return;
- fileDisplayArea.innerHTML = '';
+ fileDisplayArea.innerHTML = '';
- if (files.length > 0) {
- fileControls.classList.remove('hidden');
- extractOptions.classList.remove('hidden');
+ if (files.length > 0) {
+ fileControls.classList.remove('hidden');
+ extractOptions.classList.remove('hidden');
- files.forEach((file, index) => {
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ files.forEach((file, index) => {
+ 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 items-center gap-2 overflow-hidden';
+ const infoContainer = document.createElement('div');
+ infoContainer.className = 'flex items-center gap-2 overflow-hidden';
- const nameSpan = document.createElement('span');
- nameSpan.className = 'truncate font-medium text-gray-200';
- nameSpan.textContent = file.name;
+ const nameSpan = document.createElement('span');
+ nameSpan.className = 'truncate font-medium text-gray-200';
+ nameSpan.textContent = file.name;
- const sizeSpan = document.createElement('span');
- sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
- sizeSpan.textContent = `(${formatBytes(file.size)})`;
+ const sizeSpan = document.createElement('span');
+ sizeSpan.className = 'flex-shrink-0 text-gray-400 text-xs';
+ sizeSpan.textContent = `(${formatBytes(file.size)})`;
- infoContainer.append(nameSpan, sizeSpan);
+ infoContainer.append(nameSpan, sizeSpan);
- const removeBtn = document.createElement('button');
- removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
- removeBtn.innerHTML = '
';
- removeBtn.onclick = () => {
- files = files.filter((_, i) => i !== index);
- updateUI();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className =
+ 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = '
';
+ removeBtn.onclick = () => {
+ files = files.filter((_, i) => i !== index);
+ updateUI();
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
- });
- createIcons({ icons });
- } else {
- fileControls.classList.add('hidden');
- extractOptions.classList.add('hidden');
- }
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ });
+ createIcons({ icons });
+ } else {
+ fileControls.classList.add('hidden');
+ extractOptions.classList.add('hidden');
+ }
}
-async function ensurePyMuPDF(): Promise
{
- if (!pymupdf) {
- pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
- await pymupdf.load();
- }
- return pymupdf;
+async function ensurePyMuPDF(): Promise {
+ if (!pymupdf) {
+ pymupdf = await loadPyMuPDF();
+ }
+ return pymupdf;
}
async function extractText() {
- if (files.length === 0) {
- showAlert('No Files', 'Please select at least one PDF file.');
- return;
- }
+ if (files.length === 0) {
+ showAlert('No Files', 'Please select at least one PDF file.');
+ return;
+ }
- showLoader('Loading engine...');
+ showLoader('Loading engine...');
- try {
- const mupdf = await ensurePyMuPDF();
+ try {
+ const mupdf = await ensurePyMuPDF();
- if (files.length === 1) {
- const file = files[0];
- showLoader(`Extracting text from ${file.name}...`);
+ if (files.length === 1) {
+ const file = files[0];
+ showLoader(`Extracting text from ${file.name}...`);
- const fullText = await mupdf.pdfToText(file);
+ const fullText = await mupdf.pdfToText(file);
- const baseName = file.name.replace(/\.pdf$/i, '');
- const textBlob = new Blob([fullText], { type: 'text/plain;charset=utf-8' });
- downloadFile(textBlob, `${baseName}.txt`);
+ const baseName = file.name.replace(/\.pdf$/i, '');
+ const textBlob = new Blob([fullText], {
+ type: 'text/plain;charset=utf-8',
+ });
+ downloadFile(textBlob, `${baseName}.txt`);
- hideLoader();
- showAlert('Success', 'Text extracted successfully!', 'success', () => {
- resetState();
- });
- } else {
- showLoader('Extracting text from multiple files...');
+ hideLoader();
+ showAlert('Success', 'Text extracted successfully!', 'success', () => {
+ resetState();
+ });
+ } else {
+ showLoader('Extracting text from multiple files...');
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
+ const JSZip = (await import('jszip')).default;
+ const zip = new JSZip();
- for (let i = 0; i < files.length; i++) {
- const file = files[i];
- showLoader(`Extracting text from file ${i + 1}/${files.length}: ${file.name}...`);
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ showLoader(
+ `Extracting text from file ${i + 1}/${files.length}: ${file.name}...`
+ );
- const fullText = await mupdf.pdfToText(file);
+ const fullText = await mupdf.pdfToText(file);
- const baseName = file.name.replace(/\.pdf$/i, '');
- zip.file(`${baseName}.txt`, fullText);
- }
+ const baseName = file.name.replace(/\.pdf$/i, '');
+ zip.file(`${baseName}.txt`, fullText);
+ }
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- downloadFile(zipBlob, 'pdf-to-text.zip');
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, 'pdf-to-text.zip');
- hideLoader();
- showAlert('Success', `Extracted text from ${files.length} PDF files!`, 'success', () => {
- resetState();
- });
+ hideLoader();
+ showAlert(
+ 'Success',
+ `Extracted text from ${files.length} PDF files!`,
+ 'success',
+ () => {
+ resetState();
}
- } catch (e: any) {
- console.error('[PDFToText]', e);
- hideLoader();
- showAlert('Extraction Error', e.message || 'Failed to extract text from PDF.');
+ );
}
+ } catch (e: any) {
+ console.error('[PDFToText]', e);
+ hideLoader();
+ showAlert(
+ 'Extraction Error',
+ e.message || 'Failed to extract text from PDF.'
+ );
+ }
}
diff --git a/src/js/logic/prepare-pdf-for-ai-page.ts b/src/js/logic/prepare-pdf-for-ai-page.ts
index a1981e4..464f522 100644
--- a/src/js/logic/prepare-pdf-for-ai-page.ts
+++ b/src/js/logic/prepare-pdf-for-ai-page.ts
@@ -1,204 +1,237 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
+import {
+ downloadFile,
+ readFileAsArrayBuffer,
+ formatBytes,
+ getPDFDocument,
+} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
-
-const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const fileDisplayArea = document.getElementById('file-display-area');
- const extractOptions = document.getElementById('extract-options');
- const fileControls = document.getElementById('file-controls');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const backBtn = document.getElementById('back-to-tools');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const extractOptions = document.getElementById('extract-options');
+ const fileControls = document.getElementById('file-controls');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const backBtn = document.getElementById('back-to-tools');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
- }
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
- const updateUI = async () => {
- if (!fileDisplayArea || !extractOptions || !processBtn || !fileControls) return;
+ const updateUI = async () => {
+ if (!fileDisplayArea || !extractOptions || !processBtn || !fileControls)
+ return;
- if (state.files.length > 0) {
- fileDisplayArea.innerHTML = '';
+ if (state.files.length > 0) {
+ fileDisplayArea.innerHTML = '';
- for (let index = 0; index < state.files.length; index++) {
- const file = state.files[index];
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ for (let index = 0; index < state.files.length; index++) {
+ const file = state.files[index];
+ 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 = file.name;
+ const nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`;
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`;
- 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 = () => {
- state.files = state.files.filter((_, i) => i !== index);
- updateUI();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className =
+ 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = ' ';
+ removeBtn.onclick = () => {
+ state.files = state.files.filter((_, i) => i !== index);
+ updateUI();
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
- try {
- const arrayBuffer = await readFileAsArrayBuffer(file);
- const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
- metaSpan.textContent = `${formatBytes(file.size)} โข ${pdfDoc.numPages} pages`;
- } catch (error) {
- console.error('Error loading PDF:', error);
- metaSpan.textContent = `${formatBytes(file.size)} โข Could not load page count`;
- }
- }
-
- createIcons({ icons });
- fileControls.classList.remove('hidden');
- extractOptions.classList.remove('hidden');
- (processBtn as HTMLButtonElement).disabled = false;
- } else {
- fileDisplayArea.innerHTML = '';
- fileControls.classList.add('hidden');
- extractOptions.classList.add('hidden');
- (processBtn as HTMLButtonElement).disabled = true;
- }
- };
-
- const resetState = () => {
- state.files = [];
- state.pdfDoc = null;
- updateUI();
- };
-
- const extractForAI = async () => {
try {
- if (state.files.length === 0) {
- showAlert('No Files', 'Please select at least one PDF file.');
- return;
- }
-
- showLoader('Loading engine...');
- await pymupdf.load();
-
- const total = state.files.length;
- let completed = 0;
- let failed = 0;
-
- if (total === 1) {
- const file = state.files[0];
- showLoader(`Extracting ${file.name} for AI...`);
-
- const llamaDocs = await (pymupdf as any).pdfToLlamaIndex(file);
- const outName = file.name.replace(/\.pdf$/i, '') + '_llm.json';
- const jsonContent = JSON.stringify(llamaDocs, null, 2);
- downloadFile(new Blob([jsonContent], { type: 'application/json' }), outName);
-
- hideLoader();
- showAlert('Extraction Complete', `Successfully extracted PDF for AI/LLM use.`, 'success', () => resetState());
- } else {
- // Multiple files - create ZIP
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
-
- for (const file of state.files) {
- try {
- showLoader(`Extracting ${file.name} for AI (${completed + 1}/${total})...`);
-
- const llamaDocs = await (pymupdf as any).pdfToLlamaIndex(file);
- const outName = file.name.replace(/\.pdf$/i, '') + '_llm.json';
- const jsonContent = JSON.stringify(llamaDocs, null, 2);
- zip.file(outName, jsonContent);
-
- completed++;
- } catch (error) {
- console.error(`Failed to extract ${file.name}:`, error);
- failed++;
- }
- }
-
- showLoader('Creating ZIP archive...');
- const zipBlob = await zip.generateAsync({ type: 'blob' });
-
- downloadFile(zipBlob, 'pdf-for-ai.zip');
-
- hideLoader();
-
- if (failed === 0) {
- showAlert('Extraction Complete', `Successfully extracted ${completed} PDF(s) for AI/LLM use.`, 'success', () => resetState());
- } else {
- showAlert('Extraction Partial', `Extracted ${completed} PDF(s), failed ${failed}.`, 'warning', () => resetState());
- }
- }
- } catch (e: any) {
- hideLoader();
- showAlert('Error', `An error occurred during extraction. Error: ${e.message}`);
+ const arrayBuffer = await readFileAsArrayBuffer(file);
+ const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
+ metaSpan.textContent = `${formatBytes(file.size)} โข ${pdfDoc.numPages} pages`;
+ } catch (error) {
+ console.error('Error loading PDF:', error);
+ metaSpan.textContent = `${formatBytes(file.size)} โข Could not load page count`;
}
- };
+ }
- const handleFileSelect = (files: FileList | null) => {
- if (files && files.length > 0) {
- const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
- if (pdfFiles.length > 0) {
- state.files = [...state.files, ...pdfFiles];
- updateUI();
- }
+ createIcons({ icons });
+ fileControls.classList.remove('hidden');
+ extractOptions.classList.remove('hidden');
+ (processBtn as HTMLButtonElement).disabled = false;
+ } else {
+ fileDisplayArea.innerHTML = '';
+ fileControls.classList.add('hidden');
+ extractOptions.classList.add('hidden');
+ (processBtn as HTMLButtonElement).disabled = true;
+ }
+ };
+
+ const resetState = () => {
+ state.files = [];
+ state.pdfDoc = null;
+ updateUI();
+ };
+
+ const extractForAI = async () => {
+ try {
+ if (state.files.length === 0) {
+ showAlert('No Files', 'Please select at least one PDF file.');
+ return;
+ }
+
+ showLoader('Loading engine...');
+ const pymupdf = await loadPyMuPDF();
+
+ const total = state.files.length;
+ let completed = 0;
+ let failed = 0;
+
+ if (total === 1) {
+ const file = state.files[0];
+ showLoader(`Extracting ${file.name} for AI...`);
+
+ const llamaDocs = await (pymupdf as any).pdfToLlamaIndex(file);
+ const outName = file.name.replace(/\.pdf$/i, '') + '_llm.json';
+ const jsonContent = JSON.stringify(llamaDocs, null, 2);
+ downloadFile(
+ new Blob([jsonContent], { type: 'application/json' }),
+ outName
+ );
+
+ hideLoader();
+ showAlert(
+ 'Extraction Complete',
+ `Successfully extracted PDF for AI/LLM use.`,
+ 'success',
+ () => resetState()
+ );
+ } else {
+ // Multiple files - create ZIP
+ const JSZip = (await import('jszip')).default;
+ const zip = new JSZip();
+
+ for (const file of state.files) {
+ try {
+ showLoader(
+ `Extracting ${file.name} for AI (${completed + 1}/${total})...`
+ );
+
+ const llamaDocs = await (pymupdf as any).pdfToLlamaIndex(file);
+ const outName = file.name.replace(/\.pdf$/i, '') + '_llm.json';
+ const jsonContent = JSON.stringify(llamaDocs, null, 2);
+ zip.file(outName, jsonContent);
+
+ completed++;
+ } catch (error) {
+ console.error(`Failed to extract ${file.name}:`, error);
+ failed++;
+ }
}
- };
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
+ showLoader('Creating ZIP archive...');
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
+ downloadFile(zipBlob, 'pdf-for-ai.zip');
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
+ hideLoader();
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- handleFileSelect(e.dataTransfer?.files ?? null);
- });
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
+ if (failed === 0) {
+ showAlert(
+ 'Extraction Complete',
+ `Successfully extracted ${completed} PDF(s) for AI/LLM use.`,
+ 'success',
+ () => resetState()
+ );
+ } else {
+ showAlert(
+ 'Extraction Partial',
+ `Extracted ${completed} PDF(s), failed ${failed}.`,
+ 'warning',
+ () => resetState()
+ );
+ }
+ }
+ } catch (e: any) {
+ hideLoader();
+ showAlert(
+ 'Error',
+ `An error occurred during extraction. Error: ${e.message}`
+ );
}
+ };
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput.click();
- });
+ const handleFileSelect = (files: FileList | null) => {
+ if (files && files.length > 0) {
+ const pdfFiles = Array.from(files).filter(
+ (f) =>
+ f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
+ );
+ if (pdfFiles.length > 0) {
+ state.files = [...state.files, ...pdfFiles];
+ updateUI();
+ }
}
+ };
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', resetState);
- }
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
- if (processBtn) {
- processBtn.addEventListener('click', extractForAI);
- }
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
+
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
+
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ handleFileSelect(e.dataTransfer?.files ?? null);
+ });
+
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
+
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', resetState);
+ }
+
+ if (processBtn) {
+ processBtn.addEventListener('click', extractForAI);
+ }
});
diff --git a/src/js/logic/psd-to-pdf-page.ts b/src/js/logic/psd-to-pdf-page.ts
index 5f25ff8..6a17d23 100644
--- a/src/js/logic/psd-to-pdf-page.ts
+++ b/src/js/logic/psd-to-pdf-page.ts
@@ -2,133 +2,165 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
const ACCEPTED_EXTENSIONS = ['.psd'];
const FILETYPE_NAME = 'PSD';
-let pymupdf: PyMuPDF | null = null;
+let pymupdf: any = null;
-async function ensurePyMuPDF(): Promise {
- if (!pymupdf) {
- pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
- await pymupdf.load();
- }
- return pymupdf;
+async function ensurePyMuPDF(): Promise {
+ if (!pymupdf) {
+ pymupdf = await loadPyMuPDF();
+ }
+ return pymupdf;
}
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const fileDisplayArea = document.getElementById('file-display-area');
- const fileControls = document.getElementById('file-controls');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const backBtn = document.getElementById('back-to-tools');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const fileControls = document.getElementById('file-controls');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const backBtn = document.getElementById('back-to-tools');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
+
+ const updateUI = async () => {
+ if (!fileDisplayArea || !processBtn || !fileControls) return;
+ if (state.files.length > 0) {
+ fileDisplayArea.innerHTML = '';
+ for (let index = 0; index < state.files.length; index++) {
+ const file = state.files[index];
+ 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 nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = formatBytes(file.size);
+ 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 = () => {
+ state.files = state.files.filter((_, i) => i !== index);
+ updateUI();
+ };
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ }
+ createIcons({ icons });
+ fileControls.classList.remove('hidden');
+ processBtn.classList.remove('hidden');
+ } else {
+ fileDisplayArea.innerHTML = '';
+ fileControls.classList.add('hidden');
+ processBtn.classList.add('hidden');
}
+ };
- const updateUI = async () => {
- if (!fileDisplayArea || !processBtn || !fileControls) return;
- if (state.files.length > 0) {
- fileDisplayArea.innerHTML = '';
- for (let index = 0; index < state.files.length; index++) {
- const file = state.files[index];
- 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 nameSpan = document.createElement('div');
- nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
- nameSpan.textContent = file.name;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = formatBytes(file.size);
- 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 = () => {
- state.files = state.files.filter((_, i) => i !== index);
- updateUI();
- };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
- }
- createIcons({ icons });
- fileControls.classList.remove('hidden');
- processBtn.classList.remove('hidden');
- } else {
- fileDisplayArea.innerHTML = '';
- fileControls.classList.add('hidden');
- processBtn.classList.add('hidden');
- }
- };
+ const resetState = () => {
+ state.files = [];
+ updateUI();
+ };
- const resetState = () => {
- state.files = [];
+ const convert = async () => {
+ if (state.files.length === 0) {
+ showAlert(
+ 'No Files',
+ `Please select at least one ${FILETYPE_NAME} file.`
+ );
+ return;
+ }
+ try {
+ showLoader('Loading engine...');
+ const mupdf = await ensurePyMuPDF();
+
+ if (state.files.length === 1) {
+ const file = state.files[0];
+ showLoader(`Converting ${file.name}...`);
+ const pdfBlob = await mupdf.imageToPdf(file, { imageType: 'psd' });
+ const baseName = file.name.replace(/\.[^/.]+$/, '');
+ downloadFile(pdfBlob, `${baseName}.pdf`);
+ hideLoader();
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${file.name} to PDF.`,
+ 'success',
+ () => resetState()
+ );
+ } else {
+ showLoader('Converting multiple files...');
+ const pdfBlob = await mupdf.imagesToPdf(state.files);
+ downloadFile(pdfBlob, 'psd_to_pdf.pdf');
+ hideLoader();
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${state.files.length} PSD files to a single PDF.`,
+ 'success',
+ () => resetState()
+ );
+ }
+ } catch (err) {
+ hideLoader();
+ const message = err instanceof Error ? err.message : 'Unknown error';
+ console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
+ showAlert(
+ 'Error',
+ `An error occurred during conversion. Error: ${message}`
+ );
+ }
+ };
+
+ const handleFileSelect = (files: FileList | null) => {
+ if (files && files.length > 0) {
+ const validFiles = Array.from(files).filter((file) => {
+ const ext = '.' + file.name.split('.').pop()?.toLowerCase();
+ return ACCEPTED_EXTENSIONS.includes(ext);
+ });
+ if (validFiles.length > 0) {
+ state.files = [...state.files, ...validFiles];
updateUI();
- };
-
- const convert = async () => {
- if (state.files.length === 0) {
- showAlert('No Files', `Please select at least one ${FILETYPE_NAME} file.`);
- return;
- }
- try {
- showLoader('Loading engine...');
- const mupdf = await ensurePyMuPDF();
-
- if (state.files.length === 1) {
- const file = state.files[0];
- showLoader(`Converting ${file.name}...`);
- const pdfBlob = await mupdf.imageToPdf(file, { imageType: 'psd' });
- const baseName = file.name.replace(/\.[^/.]+$/, '');
- downloadFile(pdfBlob, `${baseName}.pdf`);
- hideLoader();
- showAlert('Conversion Complete', `Successfully converted ${file.name} to PDF.`, 'success', () => resetState());
- } else {
- showLoader('Converting multiple files...');
- const pdfBlob = await mupdf.imagesToPdf(state.files);
- downloadFile(pdfBlob, 'psd_to_pdf.pdf');
- hideLoader();
- showAlert('Conversion Complete', `Successfully converted ${state.files.length} PSD files to a single PDF.`, 'success', () => resetState());
- }
- } catch (err) {
- hideLoader();
- const message = err instanceof Error ? err.message : 'Unknown error';
- console.error(`[${FILETYPE_NAME}ToPDF] Error:`, err);
- showAlert('Error', `An error occurred during conversion. Error: ${message}`);
- }
- };
-
- const handleFileSelect = (files: FileList | null) => {
- if (files && files.length > 0) {
- const validFiles = Array.from(files).filter(file => {
- const ext = '.' + file.name.split('.').pop()?.toLowerCase();
- return ACCEPTED_EXTENSIONS.includes(ext);
- });
- if (validFiles.length > 0) {
- state.files = [...state.files, ...validFiles];
- updateUI();
- }
- }
- };
-
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => handleFileSelect((e.target as HTMLInputElement).files));
- dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('bg-gray-700'); });
- dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('bg-gray-700'); });
- dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('bg-gray-700'); handleFileSelect(e.dataTransfer?.files ?? null); });
- fileInput.addEventListener('click', () => { fileInput.value = ''; });
+ }
}
- if (addMoreBtn) addMoreBtn.addEventListener('click', () => fileInput.click());
- if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState);
- if (processBtn) processBtn.addEventListener('click', convert);
+ };
+
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) =>
+ handleFileSelect((e.target as HTMLInputElement).files)
+ );
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ handleFileSelect(e.dataTransfer?.files ?? null);
+ });
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+ if (addMoreBtn) addMoreBtn.addEventListener('click', () => fileInput.click());
+ if (clearFilesBtn) clearFilesBtn.addEventListener('click', resetState);
+ if (processBtn) processBtn.addEventListener('click', convert);
});
diff --git a/src/js/logic/rasterize-pdf-page.ts b/src/js/logic/rasterize-pdf-page.ts
index 83f081b..fc9960b 100644
--- a/src/js/logic/rasterize-pdf-page.ts
+++ b/src/js/logic/rasterize-pdf-page.ts
@@ -1,219 +1,262 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { downloadFile, readFileAsArrayBuffer, formatBytes, getPDFDocument } from '../utils/helpers.js';
+import {
+ downloadFile,
+ readFileAsArrayBuffer,
+ formatBytes,
+ getPDFDocument,
+} from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
-
-const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const fileDisplayArea = document.getElementById('file-display-area');
- const rasterizeOptions = document.getElementById('rasterize-options');
- const fileControls = document.getElementById('file-controls');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const backBtn = document.getElementById('back-to-tools');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const rasterizeOptions = document.getElementById('rasterize-options');
+ const fileControls = document.getElementById('file-controls');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const backBtn = document.getElementById('back-to-tools');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
- }
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
- const updateUI = async () => {
- if (!fileDisplayArea || !rasterizeOptions || !processBtn || !fileControls) return;
+ const updateUI = async () => {
+ if (!fileDisplayArea || !rasterizeOptions || !processBtn || !fileControls)
+ return;
- if (state.files.length > 0) {
- fileDisplayArea.innerHTML = '';
+ if (state.files.length > 0) {
+ fileDisplayArea.innerHTML = '';
- for (let index = 0; index < state.files.length; index++) {
- const file = state.files[index];
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ for (let index = 0; index < state.files.length; index++) {
+ const file = state.files[index];
+ 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 = file.name;
+ const nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`;
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`;
- 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 = () => {
- state.files = state.files.filter((_, i) => i !== index);
- updateUI();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className =
+ 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = ' ';
+ removeBtn.onclick = () => {
+ state.files = state.files.filter((_, i) => i !== index);
+ updateUI();
+ };
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
- try {
- const arrayBuffer = await readFileAsArrayBuffer(file);
- const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
- metaSpan.textContent = `${formatBytes(file.size)} โข ${pdfDoc.numPages} pages`;
- } catch (error) {
- console.error('Error loading PDF:', error);
- metaSpan.textContent = `${formatBytes(file.size)} โข Could not load page count`;
- }
- }
-
- createIcons({ icons });
- fileControls.classList.remove('hidden');
- rasterizeOptions.classList.remove('hidden');
- (processBtn as HTMLButtonElement).disabled = false;
- } else {
- fileDisplayArea.innerHTML = '';
- fileControls.classList.add('hidden');
- rasterizeOptions.classList.add('hidden');
- (processBtn as HTMLButtonElement).disabled = true;
- }
- };
-
- const resetState = () => {
- state.files = [];
- state.pdfDoc = null;
- updateUI();
- };
-
- const rasterize = async () => {
try {
- if (state.files.length === 0) {
- showAlert('No Files', 'Please select at least one PDF file.');
- return;
- }
-
- showLoader('Loading engine...');
- await pymupdf.load();
-
- // Get options from UI
- const dpi = parseInt((document.getElementById('rasterize-dpi') as HTMLSelectElement).value) || 150;
- const format = (document.getElementById('rasterize-format') as HTMLSelectElement).value as 'png' | 'jpeg';
- const grayscale = (document.getElementById('rasterize-grayscale') as HTMLInputElement).checked;
-
- const total = state.files.length;
- let completed = 0;
- let failed = 0;
-
- if (total === 1) {
- const file = state.files[0];
- showLoader(`Rasterizing ${file.name}...`);
-
- const rasterizedBlob = await (pymupdf as any).rasterizePdf(file, {
- dpi,
- format,
- grayscale,
- quality: 95
- });
-
- const outName = file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf';
- downloadFile(rasterizedBlob, outName);
-
- hideLoader();
- showAlert('Rasterization Complete', `Successfully rasterized PDF at ${dpi} DPI.`, 'success', () => resetState());
- } else {
- // Multiple files - create ZIP
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
-
- for (const file of state.files) {
- try {
- showLoader(`Rasterizing ${file.name} (${completed + 1}/${total})...`);
-
- const rasterizedBlob = await (pymupdf as any).rasterizePdf(file, {
- dpi,
- format,
- grayscale,
- quality: 95
- });
-
- const outName = file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf';
- zip.file(outName, rasterizedBlob);
-
- completed++;
- } catch (error) {
- console.error(`Failed to rasterize ${file.name}:`, error);
- failed++;
- }
- }
-
- showLoader('Creating ZIP archive...');
- const zipBlob = await zip.generateAsync({ type: 'blob' });
-
- downloadFile(zipBlob, 'rasterized-pdfs.zip');
-
- hideLoader();
-
- if (failed === 0) {
- showAlert('Rasterization Complete', `Successfully rasterized ${completed} PDF(s) at ${dpi} DPI.`, 'success', () => resetState());
- } else {
- showAlert('Rasterization Partial', `Rasterized ${completed} PDF(s), failed ${failed}.`, 'warning', () => resetState());
- }
- }
- } catch (e: any) {
- hideLoader();
- showAlert('Error', `An error occurred during rasterization. Error: ${e.message}`);
+ const arrayBuffer = await readFileAsArrayBuffer(file);
+ const pdfDoc = await getPDFDocument({ data: arrayBuffer }).promise;
+ metaSpan.textContent = `${formatBytes(file.size)} โข ${pdfDoc.numPages} pages`;
+ } catch (error) {
+ console.error('Error loading PDF:', error);
+ metaSpan.textContent = `${formatBytes(file.size)} โข Could not load page count`;
}
- };
+ }
- const handleFileSelect = (files: FileList | null) => {
- if (files && files.length > 0) {
- const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
- if (pdfFiles.length > 0) {
- state.files = [...state.files, ...pdfFiles];
- updateUI();
- }
+ createIcons({ icons });
+ fileControls.classList.remove('hidden');
+ rasterizeOptions.classList.remove('hidden');
+ (processBtn as HTMLButtonElement).disabled = false;
+ } else {
+ fileDisplayArea.innerHTML = '';
+ fileControls.classList.add('hidden');
+ rasterizeOptions.classList.add('hidden');
+ (processBtn as HTMLButtonElement).disabled = true;
+ }
+ };
+
+ const resetState = () => {
+ state.files = [];
+ state.pdfDoc = null;
+ updateUI();
+ };
+
+ const rasterize = async () => {
+ try {
+ if (state.files.length === 0) {
+ showAlert('No Files', 'Please select at least one PDF file.');
+ return;
+ }
+
+ if (!isPyMuPDFAvailable()) {
+ showWasmRequiredDialog('pymupdf');
+ return;
+ }
+
+ showLoader('Loading engine...');
+ const pymupdf = await loadPyMuPDF();
+
+ // Get options from UI
+ const dpi =
+ parseInt(
+ (document.getElementById('rasterize-dpi') as HTMLSelectElement).value
+ ) || 150;
+ const format = (
+ document.getElementById('rasterize-format') as HTMLSelectElement
+ ).value as 'png' | 'jpeg';
+ const grayscale = (
+ document.getElementById('rasterize-grayscale') as HTMLInputElement
+ ).checked;
+
+ const total = state.files.length;
+ let completed = 0;
+ let failed = 0;
+
+ if (total === 1) {
+ const file = state.files[0];
+ showLoader(`Rasterizing ${file.name}...`);
+
+ const rasterizedBlob = await (pymupdf as any).rasterizePdf(file, {
+ dpi,
+ format,
+ grayscale,
+ quality: 95,
+ });
+
+ const outName = file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf';
+ downloadFile(rasterizedBlob, outName);
+
+ hideLoader();
+ showAlert(
+ 'Rasterization Complete',
+ `Successfully rasterized PDF at ${dpi} DPI.`,
+ 'success',
+ () => resetState()
+ );
+ } else {
+ // Multiple files - create ZIP
+ const JSZip = (await import('jszip')).default;
+ const zip = new JSZip();
+
+ for (const file of state.files) {
+ try {
+ showLoader(
+ `Rasterizing ${file.name} (${completed + 1}/${total})...`
+ );
+
+ const rasterizedBlob = await (pymupdf as any).rasterizePdf(file, {
+ dpi,
+ format,
+ grayscale,
+ quality: 95,
+ });
+
+ const outName =
+ file.name.replace(/\.pdf$/i, '') + '_rasterized.pdf';
+ zip.file(outName, rasterizedBlob);
+
+ completed++;
+ } catch (error) {
+ console.error(`Failed to rasterize ${file.name}:`, error);
+ failed++;
+ }
}
- };
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
+ showLoader('Creating ZIP archive...');
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
+ downloadFile(zipBlob, 'rasterized-pdfs.zip');
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
+ hideLoader();
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- handleFileSelect(e.dataTransfer?.files ?? null);
- });
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
+ if (failed === 0) {
+ showAlert(
+ 'Rasterization Complete',
+ `Successfully rasterized ${completed} PDF(s) at ${dpi} DPI.`,
+ 'success',
+ () => resetState()
+ );
+ } else {
+ showAlert(
+ 'Rasterization Partial',
+ `Rasterized ${completed} PDF(s), failed ${failed}.`,
+ 'warning',
+ () => resetState()
+ );
+ }
+ }
+ } catch (e: any) {
+ hideLoader();
+ showAlert(
+ 'Error',
+ `An error occurred during rasterization. Error: ${e.message}`
+ );
}
+ };
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput.click();
- });
+ const handleFileSelect = (files: FileList | null) => {
+ if (files && files.length > 0) {
+ const pdfFiles = Array.from(files).filter(
+ (f) =>
+ f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
+ );
+ if (pdfFiles.length > 0) {
+ state.files = [...state.files, ...pdfFiles];
+ updateUI();
+ }
}
+ };
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', resetState);
- }
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
- if (processBtn) {
- processBtn.addEventListener('click', rasterize);
- }
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
+
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
+
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ handleFileSelect(e.dataTransfer?.files ?? null);
+ });
+
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
+
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', resetState);
+ }
+
+ if (processBtn) {
+ processBtn.addEventListener('click', rasterize);
+ }
});
diff --git a/src/js/logic/split-pdf-page.ts b/src/js/logic/split-pdf-page.ts
index e3a68ac..81ac828 100644
--- a/src/js/logic/split-pdf-page.ts
+++ b/src/js/logic/split-pdf-page.ts
@@ -1,576 +1,647 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { createIcons, icons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
-import { downloadFile, getPDFDocument, readFileAsArrayBuffer, formatBytes } from '../utils/helpers.js';
+import {
+ downloadFile,
+ getPDFDocument,
+ readFileAsArrayBuffer,
+ formatBytes,
+} from '../utils/helpers.js';
import { state } from '../state.js';
-import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js';
+import {
+ renderPagesProgressively,
+ cleanupLazyRendering,
+} from '../utils/render-utils.js';
+import { isCpdfAvailable } from '../utils/cpdf-helper.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
import JSZip from 'jszip';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
// @ts-ignore
-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();
document.addEventListener('DOMContentLoaded', () => {
- let visualSelectorRendered = false;
+ let visualSelectorRendered = false;
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const fileDisplayArea = document.getElementById('file-display-area');
- const splitOptions = document.getElementById('split-options');
- const backBtn = document.getElementById('back-to-tools');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const splitOptions = document.getElementById('split-options');
+ const backBtn = document.getElementById('back-to-tools');
- // Split Mode Elements
- const splitModeSelect = document.getElementById('split-mode') as HTMLSelectElement;
- const rangePanel = document.getElementById('range-panel');
- const visualPanel = document.getElementById('visual-select-panel');
- const evenOddPanel = document.getElementById('even-odd-panel');
- const zipOptionWrapper = document.getElementById('zip-option-wrapper');
- const allPagesPanel = document.getElementById('all-pages-panel');
- const bookmarksPanel = document.getElementById('bookmarks-panel');
- const nTimesPanel = document.getElementById('n-times-panel');
- const nTimesWarning = document.getElementById('n-times-warning');
+ // Split Mode Elements
+ const splitModeSelect = document.getElementById(
+ 'split-mode'
+ ) as HTMLSelectElement;
+ const rangePanel = document.getElementById('range-panel');
+ const visualPanel = document.getElementById('visual-select-panel');
+ const evenOddPanel = document.getElementById('even-odd-panel');
+ const zipOptionWrapper = document.getElementById('zip-option-wrapper');
+ const allPagesPanel = document.getElementById('all-pages-panel');
+ const bookmarksPanel = document.getElementById('bookmarks-panel');
+ const nTimesPanel = document.getElementById('n-times-panel');
+ const nTimesWarning = document.getElementById('n-times-warning');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
+
+ const updateUI = async () => {
+ if (state.files.length > 0) {
+ const file = state.files[0];
+ if (fileDisplayArea) {
+ fileDisplayArea.innerHTML = '';
+ 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 nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
+
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`; // Placeholder
+
+ infoContainer.append(nameSpan, metaSpan);
+
+ // Add remove button
+ const removeBtn = document.createElement('button');
+ removeBtn.className =
+ 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
+ removeBtn.innerHTML = ' ';
+ removeBtn.onclick = () => {
+ state.files = [];
+ state.pdfDoc = null;
+ updateUI();
+ };
+
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ createIcons({ icons });
+
+ // Load PDF Document
+ try {
+ if (!state.pdfDoc) {
+ showLoader('Loading PDF...');
+ const arrayBuffer = (await readFileAsArrayBuffer(
+ file
+ )) as ArrayBuffer;
+ state.pdfDoc = await PDFLibDocument.load(arrayBuffer);
+ hideLoader();
+ }
+ // Update page count
+ metaSpan.textContent = `${formatBytes(file.size)} โข ${state.pdfDoc.getPageCount()} pages`;
+ } catch (error) {
+ console.error('Error loading PDF:', error);
+ showAlert('Error', 'Failed to load PDF file.');
+ state.files = [];
+ updateUI();
+ return;
+ }
+ }
+
+ if (splitOptions) splitOptions.classList.remove('hidden');
+ } else {
+ if (fileDisplayArea) fileDisplayArea.innerHTML = '';
+ if (splitOptions) splitOptions.classList.add('hidden');
+ state.pdfDoc = null;
+ }
+ };
+
+ const renderVisualSelector = async () => {
+ if (visualSelectorRendered) return;
+
+ const container = document.getElementById('page-selector-grid');
+ if (!container) return;
+
+ visualSelectorRendered = true;
+ container.textContent = '';
+
+ // Cleanup any previous lazy loading observers
+ cleanupLazyRendering();
+
+ showLoader('Rendering page previews...');
+
+ try {
+ if (!state.pdfDoc) {
+ // If pdfDoc is not loaded yet (e.g. page refresh), try to load it from the first file
+ if (state.files.length > 0) {
+ const file = state.files[0];
+ const arrayBuffer = (await readFileAsArrayBuffer(
+ file
+ )) as ArrayBuffer;
+ state.pdfDoc = await PDFLibDocument.load(arrayBuffer);
+ } else {
+ throw new Error('No PDF document loaded');
+ }
+ }
+
+ const pdfData = await state.pdfDoc.save();
+ const pdf = await getPDFDocument({ data: pdfData }).promise;
+
+ // Function to create wrapper element for each page
+ const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
+ const wrapper = document.createElement('div');
+ wrapper.className =
+ 'page-thumbnail-wrapper p-1 border-2 border-transparent rounded-lg cursor-pointer hover:border-indigo-500 relative';
+ wrapper.dataset.pageIndex = (pageNumber - 1).toString();
+
+ const img = document.createElement('img');
+ img.src = canvas.toDataURL();
+ img.className = 'rounded-md w-full h-auto';
+
+ const p = document.createElement('p');
+ p.className = 'text-center text-xs mt-1 text-gray-300';
+ p.textContent = `Page ${pageNumber}`;
+
+ wrapper.append(img, p);
+
+ const handleSelection = (e: any) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const isSelected = wrapper.classList.contains('selected');
+
+ if (isSelected) {
+ wrapper.classList.remove('selected', 'border-indigo-500');
+ wrapper.classList.add('border-transparent');
+ } else {
+ wrapper.classList.add('selected', 'border-indigo-500');
+ wrapper.classList.remove('border-transparent');
+ }
+ };
+
+ wrapper.addEventListener('click', handleSelection);
+ wrapper.addEventListener('touchend', handleSelection);
+
+ wrapper.addEventListener('touchstart', (e) => {
+ e.preventDefault();
});
+
+ return wrapper;
+ };
+
+ // Render pages progressively with lazy loading
+ await renderPagesProgressively(pdf, container, createWrapper, {
+ batchSize: 8,
+ useLazyLoading: true,
+ lazyLoadMargin: '400px',
+ onProgress: (current, total) => {
+ showLoader(`Rendering page previews: ${current}/${total}`);
+ },
+ onBatchComplete: () => {
+ createIcons({ icons });
+ },
+ });
+ } catch (error) {
+ console.error('Error rendering visual selector:', error);
+ showAlert('Error', 'Failed to render page previews.');
+ // Reset the flag on error so the user can try again.
+ visualSelectorRendered = false;
+ } finally {
+ hideLoader();
+ }
+ };
+
+ const resetState = () => {
+ state.files = [];
+ state.pdfDoc = null;
+
+ // Reset visual selection
+ document
+ .querySelectorAll('.page-thumbnail-wrapper.selected')
+ .forEach((el) => {
+ el.classList.remove('selected', 'border-indigo-500');
+ el.classList.add('border-transparent');
+ });
+ visualSelectorRendered = false;
+ const container = document.getElementById('page-selector-grid');
+ if (container) container.innerHTML = '';
+
+ // Reset inputs
+ const pageRangeInput = document.getElementById(
+ 'page-range'
+ ) as HTMLInputElement;
+ if (pageRangeInput) pageRangeInput.value = '';
+
+ const nValueInput = document.getElementById(
+ 'split-n-value'
+ ) as HTMLInputElement;
+ if (nValueInput) nValueInput.value = '5';
+
+ // Reset radio buttons to default (range)
+ const rangeRadio = document.querySelector(
+ 'input[name="split-mode"][value="range"]'
+ ) as HTMLInputElement;
+ if (rangeRadio) {
+ rangeRadio.checked = true;
+ rangeRadio.dispatchEvent(new Event('change'));
}
- const updateUI = async () => {
- if (state.files.length > 0) {
- const file = state.files[0];
- if (fileDisplayArea) {
- fileDisplayArea.innerHTML = '';
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ // Reset split mode select
+ if (splitModeSelect) {
+ splitModeSelect.value = 'range';
+ splitModeSelect.dispatchEvent(new Event('change'));
+ }
- const infoContainer = document.createElement('div');
- infoContainer.className = 'flex flex-col overflow-hidden';
+ updateUI();
+ };
- const nameSpan = document.createElement('div');
- nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
- nameSpan.textContent = file.name;
+ const split = async () => {
+ const splitMode = splitModeSelect.value;
+ const downloadAsZip =
+ (document.getElementById('download-as-zip') as HTMLInputElement)
+ ?.checked || false;
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = `${formatBytes(file.size)} โข Loading pages...`; // Placeholder
+ showLoader('Splitting PDF...');
- infoContainer.append(nameSpan, metaSpan);
+ try {
+ if (!state.pdfDoc) throw new Error('No PDF document loaded.');
- // Add remove button
- const removeBtn = document.createElement('button');
- removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
- removeBtn.innerHTML = ' ';
- removeBtn.onclick = () => {
- state.files = [];
- state.pdfDoc = null;
- updateUI();
- };
+ const totalPages = state.pdfDoc.getPageCount();
+ let indicesToExtract: number[] = [];
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
- createIcons({ icons });
+ switch (splitMode) {
+ case 'range':
+ const pageRangeInput = (
+ document.getElementById('page-range') as HTMLInputElement
+ ).value;
+ if (!pageRangeInput) throw new Error('Choose a valid page range.');
+ const ranges = pageRangeInput.split(',');
- // Load PDF Document
- try {
- if (!state.pdfDoc) {
- showLoader('Loading PDF...');
- const arrayBuffer = await readFileAsArrayBuffer(file) as ArrayBuffer;
- state.pdfDoc = await PDFLibDocument.load(arrayBuffer);
- hideLoader();
- }
- // Update page count
- metaSpan.textContent = `${formatBytes(file.size)} โข ${state.pdfDoc.getPageCount()} pages`;
- } catch (error) {
- console.error('Error loading PDF:', error);
- showAlert('Error', 'Failed to load PDF file.');
- state.files = [];
- updateUI();
- return;
- }
+ const rangeGroups: number[][] = [];
+ for (const range of ranges) {
+ const trimmedRange = range.trim();
+ if (!trimmedRange) continue;
+
+ const groupIndices: number[] = [];
+ if (trimmedRange.includes('-')) {
+ const [start, end] = trimmedRange.split('-').map(Number);
+ if (
+ isNaN(start) ||
+ isNaN(end) ||
+ start < 1 ||
+ end > totalPages ||
+ start > end
+ )
+ continue;
+ for (let i = start; i <= end; i++) groupIndices.push(i - 1);
+ } else {
+ const pageNum = Number(trimmedRange);
+ if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages)
+ continue;
+ groupIndices.push(pageNum - 1);
}
- if (splitOptions) splitOptions.classList.remove('hidden');
+ if (groupIndices.length > 0) {
+ rangeGroups.push(groupIndices);
+ indicesToExtract.push(...groupIndices);
+ }
+ }
- } else {
- if (fileDisplayArea) fileDisplayArea.innerHTML = '';
- if (splitOptions) splitOptions.classList.add('hidden');
- state.pdfDoc = null;
- }
- };
+ if (rangeGroups.length > 1) {
+ showLoader('Creating separate PDFs for each range...');
+ const zip = new JSZip();
- const renderVisualSelector = async () => {
- if (visualSelectorRendered) return;
+ for (let i = 0; i < rangeGroups.length; i++) {
+ const group = rangeGroups[i];
+ const newPdf = await PDFLibDocument.create();
+ const copiedPages = await newPdf.copyPages(state.pdfDoc, group);
+ copiedPages.forEach((page: any) => newPdf.addPage(page));
+ const pdfBytes = await newPdf.save();
- const container = document.getElementById('page-selector-grid');
- if (!container) return;
-
- visualSelectorRendered = true;
- container.textContent = '';
-
- // Cleanup any previous lazy loading observers
- cleanupLazyRendering();
-
- showLoader('Rendering page previews...');
-
- try {
- if (!state.pdfDoc) {
- // If pdfDoc is not loaded yet (e.g. page refresh), try to load it from the first file
- if (state.files.length > 0) {
- const file = state.files[0];
- const arrayBuffer = await readFileAsArrayBuffer(file) as ArrayBuffer;
- state.pdfDoc = await PDFLibDocument.load(arrayBuffer);
- } else {
- throw new Error('No PDF document loaded');
- }
+ const minPage = Math.min(...group) + 1;
+ const maxPage = Math.max(...group) + 1;
+ const filename =
+ minPage === maxPage
+ ? `page-${minPage}.pdf`
+ : `pages-${minPage}-${maxPage}.pdf`;
+ zip.file(filename, pdfBytes);
}
- const pdfData = await state.pdfDoc.save();
- const pdf = await getPDFDocument({ data: pdfData }).promise;
-
- // Function to create wrapper element for each page
- const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
- const wrapper = document.createElement('div');
- wrapper.className =
- 'page-thumbnail-wrapper p-1 border-2 border-transparent rounded-lg cursor-pointer hover:border-indigo-500 relative';
- wrapper.dataset.pageIndex = (pageNumber - 1).toString();
-
- const img = document.createElement('img');
- img.src = canvas.toDataURL();
- img.className = 'rounded-md w-full h-auto';
-
- const p = document.createElement('p');
- p.className = 'text-center text-xs mt-1 text-gray-300';
- p.textContent = `Page ${pageNumber}`;
-
- wrapper.append(img, p);
-
- const handleSelection = (e: any) => {
- e.preventDefault();
- e.stopPropagation();
-
- const isSelected = wrapper.classList.contains('selected');
-
- if (isSelected) {
- wrapper.classList.remove('selected', 'border-indigo-500');
- wrapper.classList.add('border-transparent');
- } else {
- wrapper.classList.add('selected', 'border-indigo-500');
- wrapper.classList.remove('border-transparent');
- }
- };
-
- wrapper.addEventListener('click', handleSelection);
- wrapper.addEventListener('touchend', handleSelection);
-
- wrapper.addEventListener('touchstart', (e) => {
- e.preventDefault();
- });
-
- return wrapper;
- };
-
- // Render pages progressively with lazy loading
- await renderPagesProgressively(
- pdf,
- container,
- createWrapper,
- {
- batchSize: 8,
- useLazyLoading: true,
- lazyLoadMargin: '400px',
- onProgress: (current, total) => {
- showLoader(`Rendering page previews: ${current}/${total}`);
- },
- onBatchComplete: () => {
- createIcons({ icons });
- }
- }
- );
- } catch (error) {
- console.error('Error rendering visual selector:', error);
- showAlert('Error', 'Failed to render page previews.');
- // Reset the flag on error so the user can try again.
- visualSelectorRendered = false;
- } finally {
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, 'split-pages.zip');
hideLoader();
+ showAlert(
+ 'Success',
+ `PDF split into ${rangeGroups.length} files successfully!`,
+ 'success',
+ () => {
+ resetState();
+ }
+ );
+ return;
+ }
+ break;
+
+ case 'even-odd':
+ const choiceElement = document.querySelector(
+ 'input[name="even-odd-choice"]:checked'
+ ) as HTMLInputElement;
+ if (!choiceElement)
+ throw new Error('Please select even or odd pages.');
+ const choice = choiceElement.value;
+ for (let i = 0; i < totalPages; i++) {
+ if (choice === 'even' && (i + 1) % 2 === 0)
+ indicesToExtract.push(i);
+ if (choice === 'odd' && (i + 1) % 2 !== 0) indicesToExtract.push(i);
+ }
+ break;
+ case 'all':
+ indicesToExtract = Array.from({ length: totalPages }, (_, i) => i);
+ break;
+ case 'visual':
+ indicesToExtract = Array.from(
+ document.querySelectorAll('.page-thumbnail-wrapper.selected')
+ ).map((el) => parseInt((el as HTMLElement).dataset.pageIndex || '0'));
+ break;
+ case 'bookmarks':
+ // Check if CPDF is configured
+ if (!isCpdfAvailable()) {
+ showWasmRequiredDialog('cpdf');
+ hideLoader();
+ return;
+ }
+ const { getCpdf } = await import('../utils/cpdf-helper.js');
+ const cpdf = await getCpdf();
+ const pdfBytes = await state.pdfDoc.save();
+ const pdf = cpdf.fromMemory(new Uint8Array(pdfBytes), '');
+
+ cpdf.startGetBookmarkInfo(pdf);
+ const bookmarkCount = cpdf.numberBookmarks();
+ const bookmarkLevel = (
+ document.getElementById('bookmark-level') as HTMLSelectElement
+ )?.value;
+
+ const splitPages: number[] = [];
+ for (let i = 0; i < bookmarkCount; i++) {
+ const level = cpdf.getBookmarkLevel(i);
+ const page = cpdf.getBookmarkPage(pdf, i);
+
+ if (bookmarkLevel === 'all' || level === parseInt(bookmarkLevel)) {
+ if (page > 1 && !splitPages.includes(page - 1)) {
+ splitPages.push(page - 1); // Convert to 0-based index
+ }
+ }
+ }
+ cpdf.endGetBookmarkInfo();
+ cpdf.deletePdf(pdf);
+
+ if (splitPages.length === 0) {
+ throw new Error('No bookmarks found at the selected level.');
+ }
+
+ splitPages.sort((a, b) => a - b);
+ const zip = new JSZip();
+
+ for (let i = 0; i < splitPages.length; i++) {
+ const startPage = i === 0 ? 0 : splitPages[i];
+ const endPage =
+ i < splitPages.length - 1
+ ? splitPages[i + 1] - 1
+ : totalPages - 1;
+
+ const newPdf = await PDFLibDocument.create();
+ const pageIndices = Array.from(
+ { length: endPage - startPage + 1 },
+ (_, idx) => startPage + idx
+ );
+ const copiedPages = await newPdf.copyPages(
+ state.pdfDoc,
+ pageIndices
+ );
+ copiedPages.forEach((page: any) => newPdf.addPage(page));
+ const pdfBytes2 = await newPdf.save();
+ zip.file(`split-${i + 1}.pdf`, pdfBytes2);
+ }
+
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, 'split-by-bookmarks.zip');
+ hideLoader();
+ showAlert('Success', 'PDF split successfully!', 'success', () => {
+ resetState();
+ });
+ return;
+
+ case 'n-times':
+ const nValue = parseInt(
+ (document.getElementById('split-n-value') as HTMLInputElement)
+ ?.value || '5'
+ );
+ if (nValue < 1) throw new Error('N must be at least 1.');
+
+ const zip2 = new JSZip();
+ const numSplits = Math.ceil(totalPages / nValue);
+
+ for (let i = 0; i < numSplits; i++) {
+ const startPage = i * nValue;
+ const endPage = Math.min(startPage + nValue - 1, totalPages - 1);
+ const pageIndices = Array.from(
+ { length: endPage - startPage + 1 },
+ (_, idx) => startPage + idx
+ );
+
+ const newPdf = await PDFLibDocument.create();
+ const copiedPages = await newPdf.copyPages(
+ state.pdfDoc,
+ pageIndices
+ );
+ copiedPages.forEach((page: any) => newPdf.addPage(page));
+ const pdfBytes3 = await newPdf.save();
+ zip2.file(`split-${i + 1}.pdf`, pdfBytes3);
+ }
+
+ const zipBlob2 = await zip2.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob2, 'split-n-times.zip');
+ hideLoader();
+ showAlert('Success', 'PDF split successfully!', 'success', () => {
+ resetState();
+ });
+ return;
+ }
+
+ const uniqueIndices = [...new Set(indicesToExtract)];
+ if (
+ uniqueIndices.length === 0 &&
+ splitMode !== 'bookmarks' &&
+ splitMode !== 'n-times'
+ ) {
+ throw new Error('No pages were selected for splitting.');
+ }
+
+ if (
+ splitMode === 'all' ||
+ (['range', 'visual'].includes(splitMode) && downloadAsZip)
+ ) {
+ showLoader('Creating ZIP file...');
+ const zip = new JSZip();
+ for (const index of uniqueIndices) {
+ const newPdf = await PDFLibDocument.create();
+ const [copiedPage] = await newPdf.copyPages(state.pdfDoc, [
+ index as number,
+ ]);
+ newPdf.addPage(copiedPage);
+ const pdfBytes = await newPdf.save();
+ // @ts-ignore
+ zip.file(`page-${index + 1}.pdf`, pdfBytes);
}
- };
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, 'split-pages.zip');
+ } else {
+ const newPdf = await PDFLibDocument.create();
+ const copiedPages = await newPdf.copyPages(
+ state.pdfDoc,
+ uniqueIndices as number[]
+ );
+ copiedPages.forEach((page: any) => newPdf.addPage(page));
+ const pdfBytes = await newPdf.save();
+ downloadFile(
+ new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
+ 'split-document.pdf'
+ );
+ }
- const resetState = () => {
- state.files = [];
- state.pdfDoc = null;
+ if (splitMode === 'visual') {
+ visualSelectorRendered = false;
+ }
- // Reset visual selection
- document.querySelectorAll('.page-thumbnail-wrapper.selected').forEach(el => {
- el.classList.remove('selected', 'border-indigo-500');
- el.classList.add('border-transparent');
- });
+ showAlert('Success', 'PDF split successfully!', 'success', () => {
+ resetState();
+ });
+ } catch (e: any) {
+ console.error(e);
+ showAlert(
+ 'Error',
+ e.message || 'Failed to split PDF. Please check your selection.'
+ );
+ } finally {
+ hideLoader();
+ }
+ };
+
+ const handleFileSelect = async (files: FileList | null) => {
+ if (files && files.length > 0) {
+ // Split tool only supports one file at a time
+ state.files = [files[0]];
+ await updateUI();
+ }
+ };
+
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
+
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
+
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
+
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ const files = e.dataTransfer?.files;
+ if (files) {
+ const pdfFiles = Array.from(files).filter(
+ (f) =>
+ f.type === 'application/pdf' ||
+ f.name.toLowerCase().endsWith('.pdf')
+ );
+ if (pdfFiles.length > 0) {
+ // Take only the first PDF
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(pdfFiles[0]);
+ handleFileSelect(dataTransfer.files);
+ }
+ }
+ });
+
+ // Clear value on click to allow re-selecting the same file
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
+
+ if (splitModeSelect) {
+ splitModeSelect.addEventListener('change', (e) => {
+ const mode = (e.target as HTMLSelectElement).value;
+
+ if (mode !== 'visual') {
visualSelectorRendered = false;
const container = document.getElementById('page-selector-grid');
if (container) container.innerHTML = '';
+ }
- // Reset inputs
- const pageRangeInput = document.getElementById('page-range') as HTMLInputElement;
- if (pageRangeInput) pageRangeInput.value = '';
+ rangePanel?.classList.add('hidden');
+ visualPanel?.classList.add('hidden');
+ evenOddPanel?.classList.add('hidden');
+ allPagesPanel?.classList.add('hidden');
+ bookmarksPanel?.classList.add('hidden');
+ nTimesPanel?.classList.add('hidden');
+ zipOptionWrapper?.classList.add('hidden');
+ if (nTimesWarning) nTimesWarning.classList.add('hidden');
- const nValueInput = document.getElementById('split-n-value') as HTMLInputElement;
- if (nValueInput) nValueInput.value = '5';
+ if (mode === 'range') {
+ rangePanel?.classList.remove('hidden');
+ zipOptionWrapper?.classList.remove('hidden');
+ } else if (mode === 'visual') {
+ visualPanel?.classList.remove('hidden');
+ zipOptionWrapper?.classList.remove('hidden');
+ renderVisualSelector();
+ } else if (mode === 'even-odd') {
+ evenOddPanel?.classList.remove('hidden');
+ } else if (mode === 'all') {
+ allPagesPanel?.classList.remove('hidden');
+ } else if (mode === 'bookmarks') {
+ bookmarksPanel?.classList.remove('hidden');
+ zipOptionWrapper?.classList.remove('hidden');
+ } else if (mode === 'n-times') {
+ nTimesPanel?.classList.remove('hidden');
+ zipOptionWrapper?.classList.remove('hidden');
- // Reset radio buttons to default (range)
- const rangeRadio = document.querySelector('input[name="split-mode"][value="range"]') as HTMLInputElement;
- if (rangeRadio) {
- rangeRadio.checked = true;
- rangeRadio.dispatchEvent(new Event('change'));
- }
-
- // Reset split mode select
- if (splitModeSelect) {
- splitModeSelect.value = 'range';
- splitModeSelect.dispatchEvent(new Event('change'));
- }
-
- updateUI();
- };
-
- const split = async () => {
- const splitMode = splitModeSelect.value;
- const downloadAsZip =
- (document.getElementById('download-as-zip') as HTMLInputElement)?.checked ||
- false;
-
- showLoader('Splitting PDF...');
-
- try {
- if (!state.pdfDoc) throw new Error('No PDF document loaded.');
-
- const totalPages = state.pdfDoc.getPageCount();
- let indicesToExtract: number[] = [];
-
- switch (splitMode) {
- case 'range':
- const pageRangeInput = (document.getElementById('page-range') as HTMLInputElement).value;
- if (!pageRangeInput) throw new Error('Choose a valid page range.');
- const ranges = pageRangeInput.split(',');
-
- const rangeGroups: number[][] = [];
- for (const range of ranges) {
- const trimmedRange = range.trim();
- if (!trimmedRange) continue;
-
- const groupIndices: number[] = [];
- if (trimmedRange.includes('-')) {
- const [start, end] = trimmedRange.split('-').map(Number);
- if (
- isNaN(start) ||
- isNaN(end) ||
- start < 1 ||
- end > totalPages ||
- start > end
- )
- continue;
- for (let i = start; i <= end; i++) groupIndices.push(i - 1);
- } else {
- const pageNum = Number(trimmedRange);
- if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue;
- groupIndices.push(pageNum - 1);
- }
-
- if (groupIndices.length > 0) {
- rangeGroups.push(groupIndices);
- indicesToExtract.push(...groupIndices);
- }
- }
-
- if (rangeGroups.length > 1) {
- showLoader('Creating separate PDFs for each range...');
- const zip = new JSZip();
-
- for (let i = 0; i < rangeGroups.length; i++) {
- const group = rangeGroups[i];
- const newPdf = await PDFLibDocument.create();
- const copiedPages = await newPdf.copyPages(state.pdfDoc, group);
- copiedPages.forEach((page: any) => newPdf.addPage(page));
- const pdfBytes = await newPdf.save();
-
- const minPage = Math.min(...group) + 1;
- const maxPage = Math.max(...group) + 1;
- const filename = minPage === maxPage
- ? `page-${minPage}.pdf`
- : `pages-${minPage}-${maxPage}.pdf`;
- zip.file(filename, pdfBytes);
- }
-
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- downloadFile(zipBlob, 'split-pages.zip');
- hideLoader();
- showAlert('Success', `PDF split into ${rangeGroups.length} files successfully!`, 'success', () => {
- resetState();
- });
- return;
- }
- break;
-
- case 'even-odd':
- const choiceElement = document.querySelector(
- 'input[name="even-odd-choice"]:checked'
- ) as HTMLInputElement;
- if (!choiceElement) throw new Error('Please select even or odd pages.');
- const choice = choiceElement.value;
- for (let i = 0; i < totalPages; i++) {
- if (choice === 'even' && (i + 1) % 2 === 0) indicesToExtract.push(i);
- if (choice === 'odd' && (i + 1) % 2 !== 0) indicesToExtract.push(i);
- }
- break;
- case 'all':
- indicesToExtract = Array.from({ length: totalPages }, (_, i) => i);
- break;
- case 'visual':
- indicesToExtract = Array.from(
- document.querySelectorAll('.page-thumbnail-wrapper.selected')
- )
- .map((el) => parseInt((el as HTMLElement).dataset.pageIndex || '0'));
- break;
- case 'bookmarks':
- const { getCpdf } = await import('../utils/cpdf-helper.js');
- const cpdf = await getCpdf();
- const pdfBytes = await state.pdfDoc.save();
- const pdf = cpdf.fromMemory(new Uint8Array(pdfBytes), '');
-
- cpdf.startGetBookmarkInfo(pdf);
- const bookmarkCount = cpdf.numberBookmarks();
- const bookmarkLevel = (document.getElementById('bookmark-level') as HTMLSelectElement)?.value;
-
- const splitPages: number[] = [];
- for (let i = 0; i < bookmarkCount; i++) {
- const level = cpdf.getBookmarkLevel(i);
- const page = cpdf.getBookmarkPage(pdf, i);
-
- if (bookmarkLevel === 'all' || level === parseInt(bookmarkLevel)) {
- if (page > 1 && !splitPages.includes(page - 1)) {
- splitPages.push(page - 1); // Convert to 0-based index
- }
- }
- }
- cpdf.endGetBookmarkInfo();
- cpdf.deletePdf(pdf);
-
- if (splitPages.length === 0) {
- throw new Error('No bookmarks found at the selected level.');
- }
-
- splitPages.sort((a, b) => a - b);
- const zip = new JSZip();
-
- for (let i = 0; i < splitPages.length; i++) {
- const startPage = i === 0 ? 0 : splitPages[i];
- const endPage = i < splitPages.length - 1 ? splitPages[i + 1] - 1 : totalPages - 1;
-
- const newPdf = await PDFLibDocument.create();
- const pageIndices = Array.from({ length: endPage - startPage + 1 }, (_, idx) => startPage + idx);
- const copiedPages = await newPdf.copyPages(state.pdfDoc, pageIndices);
- copiedPages.forEach((page: any) => newPdf.addPage(page));
- const pdfBytes2 = await newPdf.save();
- zip.file(`split-${i + 1}.pdf`, pdfBytes2);
- }
-
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- downloadFile(zipBlob, 'split-by-bookmarks.zip');
- hideLoader();
- showAlert('Success', 'PDF split successfully!', 'success', () => {
- resetState();
- });
- return;
-
- case 'n-times':
- const nValue = parseInt((document.getElementById('split-n-value') as HTMLInputElement)?.value || '5');
- if (nValue < 1) throw new Error('N must be at least 1.');
-
- const zip2 = new JSZip();
- const numSplits = Math.ceil(totalPages / nValue);
-
- for (let i = 0; i < numSplits; i++) {
- const startPage = i * nValue;
- const endPage = Math.min(startPage + nValue - 1, totalPages - 1);
- const pageIndices = Array.from({ length: endPage - startPage + 1 }, (_, idx) => startPage + idx);
-
- const newPdf = await PDFLibDocument.create();
- const copiedPages = await newPdf.copyPages(state.pdfDoc, pageIndices);
- copiedPages.forEach((page: any) => newPdf.addPage(page));
- const pdfBytes3 = await newPdf.save();
- zip2.file(`split-${i + 1}.pdf`, pdfBytes3);
- }
-
- const zipBlob2 = await zip2.generateAsync({ type: 'blob' });
- downloadFile(zipBlob2, 'split-n-times.zip');
- hideLoader();
- showAlert('Success', 'PDF split successfully!', 'success', () => {
- resetState();
- });
- return;
+ const updateWarning = () => {
+ if (!state.pdfDoc) return;
+ const totalPages = state.pdfDoc.getPageCount();
+ const nValue = parseInt(
+ (document.getElementById('split-n-value') as HTMLInputElement)
+ ?.value || '5'
+ );
+ const remainder = totalPages % nValue;
+ if (remainder !== 0 && nTimesWarning) {
+ nTimesWarning.classList.remove('hidden');
+ const warningText = document.getElementById('n-times-warning-text');
+ if (warningText) {
+ warningText.textContent = `The PDF has ${totalPages} pages, which is not evenly divisible by ${nValue}. The last PDF will contain ${remainder} page(s).`;
}
+ } else if (nTimesWarning) {
+ nTimesWarning.classList.add('hidden');
+ }
+ };
- const uniqueIndices = [...new Set(indicesToExtract)];
- if (uniqueIndices.length === 0 && splitMode !== 'bookmarks' && splitMode !== 'n-times') {
- throw new Error('No pages were selected for splitting.');
- }
+ updateWarning();
+ document
+ .getElementById('split-n-value')
+ ?.addEventListener('input', updateWarning);
+ }
+ });
+ }
- if (
- splitMode === 'all' ||
- (['range', 'visual'].includes(splitMode) && downloadAsZip)
- ) {
- showLoader('Creating ZIP file...');
- const zip = new JSZip();
- for (const index of uniqueIndices) {
- const newPdf = await PDFLibDocument.create();
- const [copiedPage] = await newPdf.copyPages(state.pdfDoc, [
- index as number,
- ]);
- newPdf.addPage(copiedPage);
- const pdfBytes = await newPdf.save();
- // @ts-ignore
- zip.file(`page-${index + 1}.pdf`, pdfBytes);
- }
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- downloadFile(zipBlob, 'split-pages.zip');
- } else {
- const newPdf = await PDFLibDocument.create();
- const copiedPages = await newPdf.copyPages(
- state.pdfDoc,
- uniqueIndices as number[]
- );
- copiedPages.forEach((page: any) => newPdf.addPage(page));
- const pdfBytes = await newPdf.save();
- downloadFile(
- new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
- 'split-document.pdf'
- );
- }
-
- if (splitMode === 'visual') {
- visualSelectorRendered = false;
- }
-
- showAlert('Success', 'PDF split successfully!', 'success', () => {
- resetState();
- });
-
- } catch (e: any) {
- console.error(e);
- showAlert(
- 'Error',
- e.message || 'Failed to split PDF. Please check your selection.'
- );
- } finally {
- hideLoader();
- }
- };
-
- const handleFileSelect = async (files: FileList | null) => {
- if (files && files.length > 0) {
- // Split tool only supports one file at a time
- state.files = [files[0]];
- await updateUI();
- }
- };
-
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
-
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
-
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- const files = e.dataTransfer?.files;
- if (files) {
- const pdfFiles = Array.from(files).filter(f => f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf'));
- if (pdfFiles.length > 0) {
- // Take only the first PDF
- const dataTransfer = new DataTransfer();
- dataTransfer.items.add(pdfFiles[0]);
- handleFileSelect(dataTransfer.files);
- }
- }
- });
-
- // Clear value on click to allow re-selecting the same file
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
- }
-
- if (splitModeSelect) {
- splitModeSelect.addEventListener('change', (e) => {
- const mode = (e.target as HTMLSelectElement).value;
-
- if (mode !== 'visual') {
- visualSelectorRendered = false;
- const container = document.getElementById('page-selector-grid');
- if (container) container.innerHTML = '';
- }
-
- rangePanel?.classList.add('hidden');
- visualPanel?.classList.add('hidden');
- evenOddPanel?.classList.add('hidden');
- allPagesPanel?.classList.add('hidden');
- bookmarksPanel?.classList.add('hidden');
- nTimesPanel?.classList.add('hidden');
- zipOptionWrapper?.classList.add('hidden');
- if (nTimesWarning) nTimesWarning.classList.add('hidden');
-
- if (mode === 'range') {
- rangePanel?.classList.remove('hidden');
- zipOptionWrapper?.classList.remove('hidden');
- } else if (mode === 'visual') {
- visualPanel?.classList.remove('hidden');
- zipOptionWrapper?.classList.remove('hidden');
- renderVisualSelector();
- } else if (mode === 'even-odd') {
- evenOddPanel?.classList.remove('hidden');
- } else if (mode === 'all') {
- allPagesPanel?.classList.remove('hidden');
- } else if (mode === 'bookmarks') {
- bookmarksPanel?.classList.remove('hidden');
- zipOptionWrapper?.classList.remove('hidden');
- } else if (mode === 'n-times') {
- nTimesPanel?.classList.remove('hidden');
- zipOptionWrapper?.classList.remove('hidden');
-
- const updateWarning = () => {
- if (!state.pdfDoc) return;
- const totalPages = state.pdfDoc.getPageCount();
- const nValue = parseInt((document.getElementById('split-n-value') as HTMLInputElement)?.value || '5');
- const remainder = totalPages % nValue;
- if (remainder !== 0 && nTimesWarning) {
- nTimesWarning.classList.remove('hidden');
- const warningText = document.getElementById('n-times-warning-text');
- if (warningText) {
- warningText.textContent = `The PDF has ${totalPages} pages, which is not evenly divisible by ${nValue}. The last PDF will contain ${remainder} page(s).`;
- }
- } else if (nTimesWarning) {
- nTimesWarning.classList.add('hidden');
- }
- };
-
- updateWarning();
- document.getElementById('split-n-value')?.addEventListener('input', updateWarning);
- }
- });
- }
-
- if (processBtn) {
- processBtn.addEventListener('click', split);
- }
+ if (processBtn) {
+ processBtn.addEventListener('click', split);
+ }
});
diff --git a/src/js/logic/table-of-contents.ts b/src/js/logic/table-of-contents.ts
index 1850d74..1aa46fa 100644
--- a/src/js/logic/table-of-contents.ts
+++ b/src/js/logic/table-of-contents.ts
@@ -1,8 +1,14 @@
-import { downloadFile, formatBytes } from "../utils/helpers";
-import { initializeGlobalShortcuts } from "../utils/shortcuts-init.js";
+import { downloadFile, formatBytes } from '../utils/helpers';
+import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
+import { isCpdfAvailable } from '../utils/cpdf-helper.js';
+import {
+ showWasmRequiredDialog,
+ WasmProvider,
+} from '../utils/wasm-provider.js';
-
-const worker = new Worker(import.meta.env.BASE_URL + 'workers/table-of-contents.worker.js');
+const worker = new Worker(
+ import.meta.env.BASE_URL + 'workers/table-of-contents.worker.js'
+);
let pdfFile: File | null = null;
@@ -55,12 +61,13 @@ function showStatus(
type: 'success' | 'error' | 'info' = 'info'
) {
statusMessage.textContent = message;
- statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${type === 'success'
- ? 'bg-green-900 text-green-200'
- : type === 'error'
- ? 'bg-red-900 text-red-200'
- : 'bg-blue-900 text-blue-200'
- }`;
+ statusMessage.className = `mt-4 p-3 rounded-lg text-sm ${
+ type === 'success'
+ ? 'bg-green-900 text-green-200'
+ : type === 'error'
+ ? 'bg-red-900 text-red-200'
+ : 'bg-blue-900 text-blue-200'
+ }`;
statusMessage.classList.remove('hidden');
}
@@ -130,6 +137,12 @@ async function generateTableOfContents() {
return;
}
+ // Check if CPDF is configured
+ if (!isCpdfAvailable()) {
+ showWasmRequiredDialog('cpdf');
+ return;
+ }
+
try {
generateBtn.disabled = true;
showStatus('Reading file (Main Thread)...', 'info');
@@ -143,13 +156,14 @@ async function generateTableOfContents() {
const fontFamily = parseInt(fontFamilySelect.value, 10);
const addBookmark = addBookmarkCheckbox.checked;
- const message: GenerateTOCMessage = {
+ const message = {
command: 'generate-toc',
pdfData: arrayBuffer,
title,
fontSize,
fontFamily,
addBookmark,
+ cpdfUrl: WasmProvider.getUrl('cpdf')! + 'coherentpdf.browser.min.js',
};
worker.postMessage(message, [arrayBuffer]);
@@ -171,7 +185,10 @@ worker.onmessage = (e: MessageEvent) => {
const pdfBytes = new Uint8Array(pdfBytesBuffer);
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
- downloadFile(blob, pdfFile?.name.replace('.pdf', '_with_toc.pdf') || 'output_with_toc.pdf');
+ downloadFile(
+ blob,
+ pdfFile?.name.replace('.pdf', '_with_toc.pdf') || 'output_with_toc.pdf'
+ );
showStatus(
'Table of contents generated successfully! Download started.',
diff --git a/src/js/logic/txt-to-pdf-page.ts b/src/js/logic/txt-to-pdf-page.ts
index c8766b0..be41b94 100644
--- a/src/js/logic/txt-to-pdf-page.ts
+++ b/src/js/logic/txt-to-pdf-page.ts
@@ -1,252 +1,280 @@
import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { createIcons, icons } from 'lucide';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
let files: File[] = [];
let currentMode: 'upload' | 'text' = 'upload';
// RTL character detection pattern (Arabic, Hebrew, Persian, etc.)
-const RTL_PATTERN = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u08A0-\u08FF\uFB1D-\uFB4F\uFB50-\uFDFF\uFE70-\uFEFF]/;
+const RTL_PATTERN =
+ /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\u0780-\u07BF\u07C0-\u07FF\u08A0-\u08FF\uFB1D-\uFB4F\uFB50-\uFDFF\uFE70-\uFEFF]/;
function hasRtlCharacters(text: string): boolean {
- return RTL_PATTERN.test(text);
+ return RTL_PATTERN.test(text);
}
const updateUI = () => {
- const fileDisplayArea = document.getElementById('file-display-area');
- const fileControls = document.getElementById('file-controls');
- const dropZone = document.getElementById('drop-zone');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const fileControls = document.getElementById('file-controls');
+ const dropZone = document.getElementById('drop-zone');
- if (!fileDisplayArea || !fileControls || !dropZone) return;
+ if (!fileDisplayArea || !fileControls || !dropZone) return;
- fileDisplayArea.innerHTML = '';
+ fileDisplayArea.innerHTML = '';
- if (files.length > 0 && currentMode === 'upload') {
- dropZone.classList.add('hidden');
- fileControls.classList.remove('hidden');
+ if (files.length > 0 && currentMode === 'upload') {
+ dropZone.classList.add('hidden');
+ fileControls.classList.remove('hidden');
- files.forEach((file, index) => {
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ files.forEach((file, index) => {
+ const fileDiv = document.createElement('div');
+ fileDiv.className =
+ 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
- const infoSpan = document.createElement('span');
- infoSpan.className = 'truncate font-medium text-gray-200';
- infoSpan.textContent = file.name;
+ const infoSpan = document.createElement('span');
+ infoSpan.className = 'truncate font-medium text-gray-200';
+ infoSpan.textContent = file.name;
- const sizeSpan = document.createElement('span');
- sizeSpan.className = 'text-gray-400 text-xs ml-2';
- sizeSpan.textContent = `(${formatBytes(file.size)})`;
+ const sizeSpan = document.createElement('span');
+ sizeSpan.className = 'text-gray-400 text-xs ml-2';
+ sizeSpan.textContent = `(${formatBytes(file.size)})`;
- const removeBtn = document.createElement('button');
- removeBtn.className = 'ml-4 text-red-400 hover:text-red-300';
- removeBtn.innerHTML = ' ';
- removeBtn.onclick = () => {
- files = files.filter((_, i) => i !== index);
- updateUI();
- };
+ const removeBtn = document.createElement('button');
+ removeBtn.className = 'ml-4 text-red-400 hover:text-red-300';
+ removeBtn.innerHTML = ' ';
+ removeBtn.onclick = () => {
+ files = files.filter((_, i) => i !== index);
+ updateUI();
+ };
- fileDiv.append(infoSpan, sizeSpan, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
- });
- createIcons({ icons });
- } else {
- dropZone.classList.remove('hidden');
- fileControls.classList.add('hidden');
- }
+ fileDiv.append(infoSpan, sizeSpan, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ });
+ createIcons({ icons });
+ } else {
+ dropZone.classList.remove('hidden');
+ fileControls.classList.add('hidden');
+ }
};
const resetState = () => {
- files = [];
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const textInput = document.getElementById('text-input') as HTMLTextAreaElement;
- if (fileInput) fileInput.value = '';
- if (textInput) textInput.value = '';
- updateUI();
+ files = [];
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const textInput = document.getElementById(
+ 'text-input'
+ ) as HTMLTextAreaElement;
+ if (fileInput) fileInput.value = '';
+ if (textInput) textInput.value = '';
+ updateUI();
};
async function convert() {
- const fontSize = parseInt((document.getElementById('font-size') as HTMLInputElement).value) || 12;
- const pageSizeKey = (document.getElementById('page-size') as HTMLSelectElement).value;
- const fontName = (document.getElementById('font-family') as HTMLSelectElement)?.value || 'helv';
- const textColor = (document.getElementById('text-color') as HTMLInputElement)?.value || '#000000';
+ const fontSize =
+ parseInt(
+ (document.getElementById('font-size') as HTMLInputElement).value
+ ) || 12;
+ const pageSizeKey = (
+ document.getElementById('page-size') as HTMLSelectElement
+ ).value;
+ const fontName =
+ (document.getElementById('font-family') as HTMLSelectElement)?.value ||
+ 'helv';
+ const textColor =
+ (document.getElementById('text-color') as HTMLInputElement)?.value ||
+ '#000000';
- if (currentMode === 'upload' && files.length === 0) {
- showAlert('No Files', 'Please select at least one text file.');
- return;
+ if (currentMode === 'upload' && files.length === 0) {
+ showAlert('No Files', 'Please select at least one text file.');
+ return;
+ }
+
+ if (currentMode === 'text') {
+ const textInput = document.getElementById(
+ 'text-input'
+ ) as HTMLTextAreaElement;
+ if (!textInput.value.trim()) {
+ showAlert('No Text', 'Please enter some text to convert.');
+ return;
+ }
+ }
+
+ showLoader('Loading engine...');
+
+ try {
+ const pymupdf = await loadPyMuPDF();
+
+ let textContent = '';
+
+ if (currentMode === 'upload') {
+ for (const file of files) {
+ const text = await file.text();
+ textContent += text + '\n\n';
+ }
+ } else {
+ const textInput = document.getElementById(
+ 'text-input'
+ ) as HTMLTextAreaElement;
+ textContent = textInput.value;
}
- if (currentMode === 'text') {
- const textInput = document.getElementById('text-input') as HTMLTextAreaElement;
- if (!textInput.value.trim()) {
- showAlert('No Text', 'Please enter some text to convert.');
- return;
- }
- }
+ showLoader('Creating PDF...');
- showLoader('Loading engine...');
+ const pdfBlob = await pymupdf.textToPdf(textContent, {
+ fontSize,
+ pageSize: pageSizeKey as 'a4' | 'letter' | 'legal' | 'a3' | 'a5',
+ fontName: fontName as 'helv' | 'tiro' | 'cour' | 'times',
+ textColor,
+ margins: 72,
+ });
- try {
- const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
- await pymupdf.load();
+ downloadFile(pdfBlob, 'text_to_pdf.pdf');
- let textContent = '';
-
- if (currentMode === 'upload') {
- for (const file of files) {
- const text = await file.text();
- textContent += text + '\n\n';
- }
- } else {
- const textInput = document.getElementById('text-input') as HTMLTextAreaElement;
- textContent = textInput.value;
- }
-
- showLoader('Creating PDF...');
-
- const pdfBlob = await pymupdf.textToPdf(textContent, {
- fontSize,
- pageSize: pageSizeKey as 'a4' | 'letter' | 'legal' | 'a3' | 'a5',
- fontName: fontName as 'helv' | 'tiro' | 'cour' | 'times',
- textColor,
- margins: 72
- });
-
- downloadFile(pdfBlob, 'text_to_pdf.pdf');
-
- showAlert('Success', 'Text converted to PDF successfully!', 'success', () => {
- resetState();
- });
- } catch (e: any) {
- console.error('[TxtToPDF] Error:', e);
- showAlert('Error', `Failed to convert text to PDF. ${e.message || ''}`);
- } finally {
- hideLoader();
- }
+ showAlert(
+ 'Success',
+ 'Text converted to PDF successfully!',
+ 'success',
+ () => {
+ resetState();
+ }
+ );
+ } catch (e: any) {
+ console.error('[TxtToPDF] Error:', e);
+ showAlert('Error', `Failed to convert text to PDF. ${e.message || ''}`);
+ } finally {
+ hideLoader();
+ }
}
// Update textarea direction based on RTL detection
function updateTextareaDirection(textarea: HTMLTextAreaElement) {
- const text = textarea.value;
- if (hasRtlCharacters(text)) {
- textarea.style.direction = 'rtl';
- textarea.style.textAlign = 'right';
- } else {
- textarea.style.direction = 'ltr';
- textarea.style.textAlign = 'left';
- }
+ const text = textarea.value;
+ if (hasRtlCharacters(text)) {
+ textarea.style.direction = 'rtl';
+ textarea.style.textAlign = 'right';
+ } else {
+ textarea.style.direction = 'ltr';
+ textarea.style.textAlign = 'left';
+ }
}
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const processBtn = document.getElementById('process-btn');
- const backBtn = document.getElementById('back-to-tools');
- const uploadModeBtn = document.getElementById('txt-mode-upload-btn');
- const textModeBtn = document.getElementById('txt-mode-text-btn');
- const uploadPanel = document.getElementById('txt-upload-panel');
- const textPanel = document.getElementById('txt-text-panel');
- const textInput = document.getElementById('text-input') as HTMLTextAreaElement;
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const processBtn = document.getElementById('process-btn');
+ const backBtn = document.getElementById('back-to-tools');
+ const uploadModeBtn = document.getElementById('txt-mode-upload-btn');
+ const textModeBtn = document.getElementById('txt-mode-text-btn');
+ const uploadPanel = document.getElementById('txt-upload-panel');
+ const textPanel = document.getElementById('txt-text-panel');
+ const textInput = document.getElementById(
+ 'text-input'
+ ) as HTMLTextAreaElement;
- // Back to Tools
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
+ // Back to Tools
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
+
+ // Mode switching
+ if (uploadModeBtn && textModeBtn && uploadPanel && textPanel) {
+ uploadModeBtn.addEventListener('click', () => {
+ currentMode = 'upload';
+ uploadModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
+ uploadModeBtn.classList.add('bg-indigo-600', 'text-white');
+ textModeBtn.classList.remove('bg-indigo-600', 'text-white');
+ textModeBtn.classList.add('bg-gray-700', 'text-gray-300');
+ uploadPanel.classList.remove('hidden');
+ textPanel.classList.add('hidden');
+ });
+
+ textModeBtn.addEventListener('click', () => {
+ currentMode = 'text';
+ textModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
+ textModeBtn.classList.add('bg-indigo-600', 'text-white');
+ uploadModeBtn.classList.remove('bg-indigo-600', 'text-white');
+ uploadModeBtn.classList.add('bg-gray-700', 'text-gray-300');
+ textPanel.classList.remove('hidden');
+ uploadPanel.classList.add('hidden');
+ });
+ }
+
+ // RTL auto-detection for textarea
+ if (textInput) {
+ textInput.addEventListener('input', () => {
+ updateTextareaDirection(textInput);
+ });
+ }
+
+ // File handling
+ const handleFileSelect = (newFiles: FileList | null) => {
+ if (!newFiles || newFiles.length === 0) return;
+ const validFiles = Array.from(newFiles).filter(
+ (file) =>
+ file.name.toLowerCase().endsWith('.txt') || file.type === 'text/plain'
+ );
+
+ if (validFiles.length < newFiles.length) {
+ showAlert(
+ 'Invalid Files',
+ 'Some files were skipped. Only text files are allowed.'
+ );
}
- // Mode switching
- if (uploadModeBtn && textModeBtn && uploadPanel && textPanel) {
- uploadModeBtn.addEventListener('click', () => {
- currentMode = 'upload';
- uploadModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
- uploadModeBtn.classList.add('bg-indigo-600', 'text-white');
- textModeBtn.classList.remove('bg-indigo-600', 'text-white');
- textModeBtn.classList.add('bg-gray-700', 'text-gray-300');
- uploadPanel.classList.remove('hidden');
- textPanel.classList.add('hidden');
- });
-
- textModeBtn.addEventListener('click', () => {
- currentMode = 'text';
- textModeBtn.classList.remove('bg-gray-700', 'text-gray-300');
- textModeBtn.classList.add('bg-indigo-600', 'text-white');
- uploadModeBtn.classList.remove('bg-indigo-600', 'text-white');
- uploadModeBtn.classList.add('bg-gray-700', 'text-gray-300');
- textPanel.classList.remove('hidden');
- uploadPanel.classList.add('hidden');
- });
+ if (validFiles.length > 0) {
+ files = [...files, ...validFiles];
+ updateUI();
}
+ };
- // RTL auto-detection for textarea
- if (textInput) {
- textInput.addEventListener('input', () => {
- updateTextareaDirection(textInput);
- });
- }
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
- // File handling
- const handleFileSelect = (newFiles: FileList | null) => {
- if (!newFiles || newFiles.length === 0) return;
- const validFiles = Array.from(newFiles).filter(
- (file) => file.name.toLowerCase().endsWith('.txt') || file.type === 'text/plain'
- );
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
- if (validFiles.length < newFiles.length) {
- showAlert('Invalid Files', 'Some files were skipped. Only text files are allowed.');
- }
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
- if (validFiles.length > 0) {
- files = [...files, ...validFiles];
- updateUI();
- }
- };
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ handleFileSelect(e.dataTransfer?.files ?? null);
+ });
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
+ if (addMoreBtn && fileInput) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', () => {
+ files = [];
+ updateUI();
+ });
+ }
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- handleFileSelect(e.dataTransfer?.files ?? null);
- });
+ if (processBtn) {
+ processBtn.addEventListener('click', convert);
+ }
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
- }
-
- if (addMoreBtn && fileInput) {
- addMoreBtn.addEventListener('click', () => {
- fileInput.click();
- });
- }
-
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', () => {
- files = [];
- updateUI();
- });
- }
-
- if (processBtn) {
- processBtn.addEventListener('click', convert);
- }
-
- createIcons({ icons });
+ createIcons({ icons });
});
diff --git a/src/js/logic/wasm-settings-page.ts b/src/js/logic/wasm-settings-page.ts
new file mode 100644
index 0000000..da3d9ba
--- /dev/null
+++ b/src/js/logic/wasm-settings-page.ts
@@ -0,0 +1,219 @@
+import { createIcons, icons } from 'lucide';
+import { showAlert, showLoader, hideLoader } from '../ui.js';
+import { WasmProvider, type WasmPackage } from '../utils/wasm-provider.js';
+import { clearPyMuPDFCache } from '../utils/pymupdf-loader.js';
+import { clearGhostscriptCache } from '../utils/ghostscript-dynamic-loader.js';
+
+if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initializePage);
+} else {
+ initializePage();
+}
+
+function initializePage() {
+ createIcons({ icons });
+
+ document.querySelectorAll('.copy-btn').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const url = btn.getAttribute('data-copy');
+ if (url) {
+ await navigator.clipboard.writeText(url);
+ const svg = btn.querySelector('svg');
+ if (svg) {
+ const checkIcon = document.createElement('i');
+ checkIcon.setAttribute('data-lucide', 'check');
+ checkIcon.className = 'w-3.5 h-3.5';
+ svg.replaceWith(checkIcon);
+ createIcons({ icons });
+
+ setTimeout(() => {
+ const newSvg = btn.querySelector('svg');
+ if (newSvg) {
+ const copyIcon = document.createElement('i');
+ copyIcon.setAttribute('data-lucide', 'copy');
+ copyIcon.className = 'w-3.5 h-3.5';
+ newSvg.replaceWith(copyIcon);
+ createIcons({ icons });
+ }
+ }, 1500);
+ }
+ }
+ });
+ });
+
+ const pymupdfUrl = document.getElementById('pymupdf-url') as HTMLInputElement;
+ const pymupdfTest = document.getElementById(
+ 'pymupdf-test'
+ ) as HTMLButtonElement;
+ const pymupdfStatus = document.getElementById(
+ 'pymupdf-status'
+ ) as HTMLSpanElement;
+
+ const ghostscriptUrl = document.getElementById(
+ 'ghostscript-url'
+ ) as HTMLInputElement;
+ const ghostscriptTest = document.getElementById(
+ 'ghostscript-test'
+ ) as HTMLButtonElement;
+ const ghostscriptStatus = document.getElementById(
+ 'ghostscript-status'
+ ) as HTMLSpanElement;
+
+ const cpdfUrl = document.getElementById('cpdf-url') as HTMLInputElement;
+ const cpdfTest = document.getElementById('cpdf-test') as HTMLButtonElement;
+ const cpdfStatus = document.getElementById('cpdf-status') as HTMLSpanElement;
+
+ const saveBtn = document.getElementById('save-btn') as HTMLButtonElement;
+ const clearBtn = document.getElementById('clear-btn') as HTMLButtonElement;
+ const backBtn = document.getElementById('back-to-tools');
+
+ backBtn?.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+
+ loadConfiguration();
+
+ function loadConfiguration() {
+ const config = WasmProvider.getAllProviders();
+
+ if (config.pymupdf) {
+ pymupdfUrl.value = config.pymupdf;
+ updateStatus('pymupdf', true);
+ }
+
+ if (config.ghostscript) {
+ ghostscriptUrl.value = config.ghostscript;
+ updateStatus('ghostscript', true);
+ }
+
+ if (config.cpdf) {
+ cpdfUrl.value = config.cpdf;
+ updateStatus('cpdf', true);
+ }
+ }
+
+ function updateStatus(
+ packageName: WasmPackage,
+ configured: boolean,
+ testing = false
+ ) {
+ const statusMap: Record = {
+ pymupdf: pymupdfStatus,
+ ghostscript: ghostscriptStatus,
+ cpdf: cpdfStatus,
+ };
+
+ const statusEl = statusMap[packageName];
+ if (!statusEl) return;
+
+ if (testing) {
+ statusEl.textContent = 'Testing...';
+ statusEl.className =
+ 'text-xs px-2 py-1 rounded-full bg-yellow-600/30 text-yellow-300';
+ } else if (configured) {
+ statusEl.textContent = 'Configured';
+ statusEl.className =
+ 'text-xs px-2 py-1 rounded-full bg-green-600/30 text-green-300';
+ } else {
+ statusEl.textContent = 'Not Configured';
+ statusEl.className =
+ 'text-xs px-2 py-1 rounded-full bg-gray-600 text-gray-300';
+ }
+ }
+
+ async function testConnection(packageName: WasmPackage, url: string) {
+ if (!url.trim()) {
+ showAlert('Empty URL', 'Please enter a URL to test.');
+ return;
+ }
+
+ updateStatus(packageName, false, true);
+
+ const result = await WasmProvider.validateUrl(packageName, url);
+
+ if (result.valid) {
+ updateStatus(packageName, true);
+ showAlert(
+ 'Success',
+ `Connection to ${WasmProvider.getPackageDisplayName(packageName)} successful!`,
+ 'success'
+ );
+ } else {
+ updateStatus(packageName, false);
+ showAlert(
+ 'Connection Failed',
+ result.error || 'Could not connect to the URL.'
+ );
+ }
+ }
+
+ pymupdfTest?.addEventListener('click', () => {
+ testConnection('pymupdf', pymupdfUrl.value);
+ });
+
+ ghostscriptTest?.addEventListener('click', () => {
+ testConnection('ghostscript', ghostscriptUrl.value);
+ });
+
+ cpdfTest?.addEventListener('click', () => {
+ testConnection('cpdf', cpdfUrl.value);
+ });
+
+ saveBtn?.addEventListener('click', async () => {
+ showLoader('Saving configuration...');
+
+ try {
+ if (pymupdfUrl.value.trim()) {
+ WasmProvider.setUrl('pymupdf', pymupdfUrl.value.trim());
+ updateStatus('pymupdf', true);
+ } else {
+ WasmProvider.removeUrl('pymupdf');
+ updateStatus('pymupdf', false);
+ }
+
+ if (ghostscriptUrl.value.trim()) {
+ WasmProvider.setUrl('ghostscript', ghostscriptUrl.value.trim());
+ updateStatus('ghostscript', true);
+ } else {
+ WasmProvider.removeUrl('ghostscript');
+ updateStatus('ghostscript', false);
+ }
+
+ if (cpdfUrl.value.trim()) {
+ WasmProvider.setUrl('cpdf', cpdfUrl.value.trim());
+ updateStatus('cpdf', true);
+ } else {
+ WasmProvider.removeUrl('cpdf');
+ updateStatus('cpdf', false);
+ }
+
+ hideLoader();
+ showAlert('Saved', 'Configuration saved successfully!', 'success');
+ } catch (e: unknown) {
+ hideLoader();
+ const errorMessage = e instanceof Error ? e.message : 'Unknown error';
+ showAlert('Error', `Failed to save configuration: ${errorMessage}`);
+ }
+ });
+
+ clearBtn?.addEventListener('click', () => {
+ WasmProvider.clearAll();
+
+ clearPyMuPDFCache();
+ clearGhostscriptCache();
+
+ pymupdfUrl.value = '';
+ ghostscriptUrl.value = '';
+ cpdfUrl.value = '';
+
+ updateStatus('pymupdf', false);
+ updateStatus('ghostscript', false);
+ updateStatus('cpdf', false);
+
+ showAlert(
+ 'Cleared',
+ 'All configurations and cached modules have been cleared.',
+ 'success'
+ );
+ });
+}
diff --git a/src/js/logic/xps-to-pdf-page.ts b/src/js/logic/xps-to-pdf-page.ts
index f3af175..a0af021 100644
--- a/src/js/logic/xps-to-pdf-page.ts
+++ b/src/js/logic/xps-to-pdf-page.ts
@@ -2,201 +2,212 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, formatBytes } from '../utils/helpers.js';
import { state } from '../state.js';
import { createIcons, icons } from 'lucide';
-import { PyMuPDF } from '@bentopdf/pymupdf-wasm';
-import { getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { isWasmAvailable, getWasmBaseUrl } from '../config/wasm-cdn-config.js';
+import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
+import { loadPyMuPDF, isPyMuPDFAvailable } from '../utils/pymupdf-loader.js';
const FILETYPE = 'xps';
const EXTENSIONS = ['.xps', '.oxps'];
const TOOL_NAME = 'XPS';
document.addEventListener('DOMContentLoaded', () => {
- const fileInput = document.getElementById('file-input') as HTMLInputElement;
- const dropZone = document.getElementById('drop-zone');
- const processBtn = document.getElementById('process-btn');
- const fileDisplayArea = document.getElementById('file-display-area');
- const fileControls = document.getElementById('file-controls');
- const addMoreBtn = document.getElementById('add-more-btn');
- const clearFilesBtn = document.getElementById('clear-files-btn');
- const backBtn = document.getElementById('back-to-tools');
+ const fileInput = document.getElementById('file-input') as HTMLInputElement;
+ const dropZone = document.getElementById('drop-zone');
+ const processBtn = document.getElementById('process-btn');
+ const fileDisplayArea = document.getElementById('file-display-area');
+ const fileControls = document.getElementById('file-controls');
+ const addMoreBtn = document.getElementById('add-more-btn');
+ const clearFilesBtn = document.getElementById('clear-files-btn');
+ const backBtn = document.getElementById('back-to-tools');
- if (backBtn) {
- backBtn.addEventListener('click', () => {
- window.location.href = import.meta.env.BASE_URL;
- });
+ if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ window.location.href = import.meta.env.BASE_URL;
+ });
+ }
+
+ const updateUI = async () => {
+ if (!fileDisplayArea || !processBtn || !fileControls) return;
+
+ if (state.files.length > 0) {
+ fileDisplayArea.innerHTML = '';
+
+ for (let index = 0; index < state.files.length; index++) {
+ const file = state.files[index];
+ 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 nameSpan = document.createElement('div');
+ nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
+ nameSpan.textContent = file.name;
+
+ const metaSpan = document.createElement('div');
+ metaSpan.className = 'text-xs text-gray-400';
+ metaSpan.textContent = formatBytes(file.size);
+
+ 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 = () => {
+ state.files = state.files.filter((_, i) => i !== index);
+ updateUI();
+ };
+
+ fileDiv.append(infoContainer, removeBtn);
+ fileDisplayArea.appendChild(fileDiv);
+ }
+
+ createIcons({ icons });
+ fileControls.classList.remove('hidden');
+ processBtn.classList.remove('hidden');
+ (processBtn as HTMLButtonElement).disabled = false;
+ } else {
+ fileDisplayArea.innerHTML = '';
+ fileControls.classList.add('hidden');
+ processBtn.classList.add('hidden');
+ (processBtn as HTMLButtonElement).disabled = true;
}
+ };
- const updateUI = async () => {
- if (!fileDisplayArea || !processBtn || !fileControls) return;
+ const resetState = () => {
+ state.files = [];
+ state.pdfDoc = null;
+ updateUI();
+ };
- if (state.files.length > 0) {
- fileDisplayArea.innerHTML = '';
+ const convertToPdf = async () => {
+ try {
+ if (state.files.length === 0) {
+ showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
+ return;
+ }
- for (let index = 0; index < state.files.length; index++) {
- const file = state.files[index];
- const fileDiv = document.createElement('div');
- fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
+ showLoader('Loading engine...');
+ const pymupdf = await loadPyMuPDF();
- const infoContainer = document.createElement('div');
- infoContainer.className = 'flex flex-col overflow-hidden';
+ if (state.files.length === 1) {
+ const originalFile = state.files[0];
+ showLoader(`Converting ${originalFile.name}...`);
- const nameSpan = document.createElement('div');
- nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
- nameSpan.textContent = file.name;
+ const pdfBlob = await pymupdf.convertToPdf(originalFile, {
+ filetype: FILETYPE,
+ });
+ const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
- const metaSpan = document.createElement('div');
- metaSpan.className = 'text-xs text-gray-400';
- metaSpan.textContent = formatBytes(file.size);
+ downloadFile(pdfBlob, fileName);
+ hideLoader();
- infoContainer.append(nameSpan, metaSpan);
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${originalFile.name} to PDF.`,
+ 'success',
+ () => resetState()
+ );
+ } else {
+ showLoader('Converting files...');
+ const JSZip = (await import('jszip')).default;
+ const zip = new JSZip();
- const removeBtn = document.createElement('button');
- removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
- removeBtn.innerHTML = ' ';
- removeBtn.onclick = () => {
- state.files = state.files.filter((_, i) => i !== index);
- updateUI();
- };
+ for (let i = 0; i < state.files.length; i++) {
+ const file = state.files[i];
+ showLoader(
+ `Converting ${i + 1}/${state.files.length}: ${file.name}...`
+ );
- fileDiv.append(infoContainer, removeBtn);
- fileDisplayArea.appendChild(fileDiv);
- }
-
- createIcons({ icons });
- fileControls.classList.remove('hidden');
- processBtn.classList.remove('hidden');
- (processBtn as HTMLButtonElement).disabled = false;
- } else {
- fileDisplayArea.innerHTML = '';
- fileControls.classList.add('hidden');
- processBtn.classList.add('hidden');
- (processBtn as HTMLButtonElement).disabled = true;
+ const pdfBlob = await pymupdf.convertToPdf(file, {
+ filetype: FILETYPE,
+ });
+ const baseName = file.name.replace(/\.[^.]+$/, '');
+ const pdfBuffer = await pdfBlob.arrayBuffer();
+ zip.file(`${baseName}.pdf`, pdfBuffer);
}
- };
- const resetState = () => {
- state.files = [];
- state.pdfDoc = null;
- updateUI();
- };
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ downloadFile(zipBlob, `${FILETYPE}-converted.zip`);
- const convertToPdf = async () => {
- try {
- if (state.files.length === 0) {
- showAlert('No Files', `Please select at least one ${TOOL_NAME} file.`);
- return;
- }
+ hideLoader();
- showLoader('Loading engine...');
- const pymupdf = new PyMuPDF(getWasmBaseUrl('pymupdf'));
- await pymupdf.load();
+ showAlert(
+ 'Conversion Complete',
+ `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
+ 'success',
+ () => resetState()
+ );
+ }
+ } catch (e: any) {
+ console.error(`[${TOOL_NAME}2PDF] ERROR:`, e);
+ hideLoader();
+ showAlert(
+ 'Error',
+ `An error occurred during conversion. Error: ${e.message}`
+ );
+ }
+ };
- if (state.files.length === 1) {
- const originalFile = state.files[0];
- showLoader(`Converting ${originalFile.name}...`);
+ const handleFileSelect = (files: FileList | null) => {
+ if (files && files.length > 0) {
+ state.files = [...state.files, ...Array.from(files)];
+ updateUI();
+ }
+ };
- const pdfBlob = await pymupdf.convertToPdf(originalFile, { filetype: FILETYPE });
- const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf';
+ if (fileInput && dropZone) {
+ fileInput.addEventListener('change', (e) => {
+ handleFileSelect((e.target as HTMLInputElement).files);
+ });
- downloadFile(pdfBlob, fileName);
- hideLoader();
+ dropZone.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ dropZone.classList.add('bg-gray-700');
+ });
- showAlert(
- 'Conversion Complete',
- `Successfully converted ${originalFile.name} to PDF.`,
- 'success',
- () => resetState()
- );
- } else {
- showLoader('Converting files...');
- const JSZip = (await import('jszip')).default;
- const zip = new JSZip();
+ dropZone.addEventListener('dragleave', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ });
- for (let i = 0; i < state.files.length; i++) {
- const file = state.files[i];
- showLoader(`Converting ${i + 1}/${state.files.length}: ${file.name}...`);
-
- const pdfBlob = await pymupdf.convertToPdf(file, { filetype: FILETYPE });
- const baseName = file.name.replace(/\.[^.]+$/, '');
- const pdfBuffer = await pdfBlob.arrayBuffer();
- zip.file(`${baseName}.pdf`, pdfBuffer);
- }
-
- const zipBlob = await zip.generateAsync({ type: 'blob' });
- downloadFile(zipBlob, `${FILETYPE}-converted.zip`);
-
- hideLoader();
-
- showAlert(
- 'Conversion Complete',
- `Successfully converted ${state.files.length} ${TOOL_NAME} file(s) to PDF.`,
- 'success',
- () => resetState()
- );
- }
- } catch (e: any) {
- console.error(`[${TOOL_NAME}2PDF] ERROR:`, e);
- hideLoader();
- showAlert('Error', `An error occurred during conversion. Error: ${e.message}`);
+ dropZone.addEventListener('drop', (e) => {
+ e.preventDefault();
+ dropZone.classList.remove('bg-gray-700');
+ const files = e.dataTransfer?.files;
+ if (files && files.length > 0) {
+ const validFiles = Array.from(files).filter((f) => {
+ const name = f.name.toLowerCase();
+ return EXTENSIONS.some((ext) => name.endsWith(ext));
+ });
+ if (validFiles.length > 0) {
+ const dataTransfer = new DataTransfer();
+ validFiles.forEach((f) => dataTransfer.items.add(f));
+ handleFileSelect(dataTransfer.files);
}
- };
+ }
+ });
- const handleFileSelect = (files: FileList | null) => {
- if (files && files.length > 0) {
- state.files = [...state.files, ...Array.from(files)];
- updateUI();
- }
- };
+ fileInput.addEventListener('click', () => {
+ fileInput.value = '';
+ });
+ }
- if (fileInput && dropZone) {
- fileInput.addEventListener('change', (e) => {
- handleFileSelect((e.target as HTMLInputElement).files);
- });
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
- dropZone.addEventListener('dragover', (e) => {
- e.preventDefault();
- dropZone.classList.add('bg-gray-700');
- });
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', () => {
+ resetState();
+ });
+ }
- dropZone.addEventListener('dragleave', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- });
-
- dropZone.addEventListener('drop', (e) => {
- e.preventDefault();
- dropZone.classList.remove('bg-gray-700');
- const files = e.dataTransfer?.files;
- if (files && files.length > 0) {
- const validFiles = Array.from(files).filter(f => {
- const name = f.name.toLowerCase();
- return EXTENSIONS.some(ext => name.endsWith(ext));
- });
- if (validFiles.length > 0) {
- const dataTransfer = new DataTransfer();
- validFiles.forEach(f => dataTransfer.items.add(f));
- handleFileSelect(dataTransfer.files);
- }
- }
- });
-
- fileInput.addEventListener('click', () => {
- fileInput.value = '';
- });
- }
-
- if (addMoreBtn) {
- addMoreBtn.addEventListener('click', () => {
- fileInput.click();
- });
- }
-
- if (clearFilesBtn) {
- clearFilesBtn.addEventListener('click', () => {
- resetState();
- });
- }
-
- if (processBtn) {
- processBtn.addEventListener('click', convertToPdf);
- }
+ if (processBtn) {
+ processBtn.addEventListener('click', convertToPdf);
+ }
});
diff --git a/src/js/main.ts b/src/js/main.ts
index 74bbf28..d05df06 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';
@@ -271,6 +207,10 @@ const init = async () => {
'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 0000000..805beb5
--- /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/index.ts b/src/js/types/index.ts
index e20d2c1..089a0ad 100644
--- a/src/js/types/index.ts
+++ b/src/js/types/index.ts
@@ -46,3 +46,4 @@ 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 b1ef7e4..00a340d 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/cpdf-helper.ts b/src/js/utils/cpdf-helper.ts
index 4987480..a1a004b 100644
--- a/src/js/utils/cpdf-helper.ts
+++ b/src/js/utils/cpdf-helper.ts
@@ -1,15 +1,35 @@
+import { WasmProvider } from './wasm-provider';
+
let cpdfLoaded = false;
let cpdfLoadPromise: Promise | null = null;
-//TODO: @ALAM,is it better to use a worker to load the cpdf library?
-// or just use the browser version?
-export async function ensureCpdfLoaded(): Promise {
+function getCpdfUrl(): string | undefined {
+ const userUrl = WasmProvider.getUrl('cpdf');
+ if (userUrl) {
+ const baseUrl = userUrl.endsWith('/') ? userUrl : `${userUrl}/`;
+ return `${baseUrl}coherentpdf.browser.min.js`;
+ }
+ return undefined;
+}
+
+export function isCpdfAvailable(): boolean {
+ return WasmProvider.isConfigured('cpdf');
+}
+
+export async function isCpdfLoaded(): Promise {
if (cpdfLoaded) return;
if (cpdfLoadPromise) {
return cpdfLoadPromise;
}
+ const cpdfUrl = getCpdfUrl();
+ if (!cpdfUrl) {
+ throw new Error(
+ 'CoherentPDF is not configured. Please configure it in WASM Settings.'
+ );
+ }
+
cpdfLoadPromise = new Promise((resolve, reject) => {
if (typeof (window as any).coherentpdf !== 'undefined') {
cpdfLoaded = true;
@@ -18,13 +38,14 @@ export async function ensureCpdfLoaded(): Promise {
}
const script = document.createElement('script');
- script.src = import.meta.env.BASE_URL + 'coherentpdf.browser.min.js';
+ script.src = cpdfUrl;
script.onload = () => {
cpdfLoaded = true;
+ console.log('[CPDF] Loaded from:', script.src);
resolve();
};
script.onerror = () => {
- reject(new Error('Failed to load CoherentPDF library'));
+ reject(new Error('Failed to load CoherentPDF library from: ' + cpdfUrl));
};
document.head.appendChild(script);
});
@@ -32,11 +53,7 @@ export async function ensureCpdfLoaded(): Promise {
return cpdfLoadPromise;
}
-/**
- * Gets the cpdf instance, ensuring it's loaded first
- */
export async function getCpdf(): Promise {
- await ensureCpdfLoaded();
+ await isCpdfLoaded();
return (window as any).coherentpdf;
}
-
diff --git a/src/js/utils/ghostscript-dynamic-loader.ts b/src/js/utils/ghostscript-dynamic-loader.ts
new file mode 100644
index 0000000..761b1bb
--- /dev/null
+++ b/src/js/utils/ghostscript-dynamic-loader.ts
@@ -0,0 +1,89 @@
+import { WasmProvider } from './wasm-provider.js';
+
+let cachedGS: any = null;
+let loadPromise: Promise | null = null;
+
+export interface GhostscriptInterface {
+ convertToPDFA(pdfBuffer: ArrayBuffer, profile: string): Promise;
+ fontToOutline(pdfBuffer: ArrayBuffer): Promise;
+}
+
+export async function loadGhostscript(): Promise {
+ if (cachedGS) {
+ return cachedGS;
+ }
+
+ if (loadPromise) {
+ return loadPromise;
+ }
+
+ loadPromise = (async () => {
+ const baseUrl = WasmProvider.getUrl('ghostscript');
+ if (!baseUrl) {
+ throw new Error(
+ 'Ghostscript is not configured. Please configure it in Advanced Settings.'
+ );
+ }
+
+ const normalizedUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
+
+ try {
+ const wrapperUrl = `${normalizedUrl}gs.js`;
+
+ await loadScript(wrapperUrl);
+
+ const globalScope =
+ typeof globalThis !== 'undefined' ? globalThis : window;
+
+ if (typeof (globalScope as any).loadGS === 'function') {
+ cachedGS = await (globalScope as any).loadGS({
+ baseUrl: normalizedUrl,
+ });
+ } else if (typeof (globalScope as any).GhostscriptWASM === 'function') {
+ cachedGS = new (globalScope as any).GhostscriptWASM(normalizedUrl);
+ await cachedGS.init?.();
+ } else {
+ throw new Error(
+ 'Ghostscript wrapper did not expose expected interface. Expected loadGS() or GhostscriptWASM class.'
+ );
+ }
+
+ return cachedGS;
+ } catch (error: any) {
+ loadPromise = null;
+ throw new Error(
+ `Failed to load Ghostscript from ${normalizedUrl}: ${error.message}`
+ );
+ }
+ })();
+
+ return loadPromise;
+}
+
+function loadScript(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ if (document.querySelector(`script[src="${url}"]`)) {
+ resolve();
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.src = url;
+ script.type = 'text/javascript';
+ script.async = true;
+
+ script.onload = () => resolve();
+ script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
+
+ document.head.appendChild(script);
+ });
+}
+
+export function isGhostscriptAvailable(): boolean {
+ return WasmProvider.isConfigured('ghostscript');
+}
+
+export function clearGhostscriptCache(): void {
+ cachedGS = null;
+ loadPromise = null;
+}
diff --git a/src/js/utils/ghostscript-loader.ts b/src/js/utils/ghostscript-loader.ts
index e08e10a..3ed86a0 100644
--- a/src/js/utils/ghostscript-loader.ts
+++ b/src/js/utils/ghostscript-loader.ts
@@ -1,10 +1,14 @@
/**
* PDF/A Conversion using Ghostscript WASM
- * * Converts PDFs to PDF/A-1b, PDF/A-2b, or PDF/A-3b format.
+ * Converts PDFs to PDF/A-1b, PDF/A-2b, or PDF/A-3b format.
+ * Requires user to configure Ghostscript URL in WASM Settings.
*/
-import loadWASM from '@bentopdf/gs-wasm';
-import { getWasmBaseUrl, fetchWasmFile } from '../config/wasm-cdn-config.js';
+import {
+ getWasmBaseUrl,
+ fetchWasmFile,
+ isWasmAvailable,
+} from '../config/wasm-cdn-config.js';
import { PDFDocument, PDFDict, PDFName, PDFArray } from 'pdf-lib';
interface GhostscriptModule {
@@ -29,11 +33,49 @@ export function getCachedGsModule(): GhostscriptModule | null {
return cachedGsModule;
}
+export async function loadGsModule(): Promise {
+ const gsBaseUrl = getWasmBaseUrl('ghostscript')!;
+ const normalizedUrl = gsBaseUrl.endsWith('/') ? gsBaseUrl : `${gsBaseUrl}/`;
+
+ const gsJsUrl = `${normalizedUrl}gs.js`;
+ const response = await fetch(gsJsUrl);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch gs.js: HTTP ${response.status}`);
+ }
+ const jsText = await response.text();
+ const blob = new Blob([jsText], { type: 'application/javascript' });
+ const blobUrl = URL.createObjectURL(blob);
+
+ try {
+ const gsModule = await import(/* @vite-ignore */ blobUrl);
+ const ModuleFactory = gsModule.default;
+
+ return (await ModuleFactory({
+ locateFile: (path: string) => {
+ if (path.endsWith('.wasm')) {
+ return `${normalizedUrl}gs.wasm`;
+ }
+ return `${normalizedUrl}${path}`;
+ },
+ print: (text: string) => console.log('[GS]', text),
+ printErr: (text: string) => console.error('[GS Error]', text),
+ })) as GhostscriptModule;
+ } finally {
+ URL.revokeObjectURL(blobUrl);
+ }
+}
+
export async function convertToPdfA(
pdfData: Uint8Array,
level: PdfALevel = 'PDF/A-2b',
onProgress?: (msg: string) => void
): Promise {
+ if (!isWasmAvailable('ghostscript')) {
+ throw new Error(
+ 'Ghostscript is not configured. Please configure it in WASM Settings.'
+ );
+ }
+
onProgress?.('Loading Ghostscript...');
let gs: GhostscriptModule;
@@ -41,17 +83,7 @@ export async function convertToPdfA(
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;
+ gs = await loadGsModule();
cachedGsModule = gs;
}
@@ -73,19 +105,28 @@ export async function convertToPdfA(
try {
const iccFileName = 'sRGB_IEC61966-2-1_no_black_scaling.icc';
- const response = await fetchWasmFile('ghostscript', iccFileName);
+ const iccUrl = `${import.meta.env.BASE_URL}${iccFileName}`;
+ const response = await fetch(iccUrl);
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 from ${iccUrl}: HTTP ${response.status}`
+ );
}
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 +155,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 +206,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 +241,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 +279,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 +306,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 +386,96 @@ 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 {
+ if (!isWasmAvailable('ghostscript')) {
+ throw new Error(
+ 'Ghostscript is not configured. Please configure it in WASM Settings.'
+ );
+ }
+
+ onProgress?.('Loading Ghostscript...');
+
+ let gs: GhostscriptModule;
+
+ if (cachedGsModule) {
+ gs = cachedGsModule;
+ } else {
+ gs = await loadGsModule();
+ 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 b760234..b5afd5f 100644
--- a/src/js/utils/helpers.ts
+++ b/src/js/utils/helpers.ts
@@ -306,3 +306,157 @@ export function escapeHtml(text: string): string {
};
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(/