From 649aec046d2967c7b3332146ee312281f76456a5 Mon Sep 17 00:00:00 2001
From: abdullahalam123
Date: Wed, 3 Dec 2025 23:13:14 +0530
Subject: [PATCH 01/21] feat(ocr,form-creator): Add comprehensive font support
and TypeScript type definitions
- Add @pdf-lib/fontkit dependency for enhanced font rendering capabilities
- Create font-mappings.ts configuration with language-to-font-family mappings for 100+ languages
- Implement font-loader.ts utility for dynamic font loading from CDN sources
- Add TypeScript type definitions for form-creator, OCR, and general application types
- Create types/index.ts as centralized type exports
- Remove hidden-on-touch CSS class and update shortcuts button styling for better accessibility
- Update OCR text layer rendering to support multilingual font families
- Enhance form-creator with improved font handling for international text
- Update txt-to-pdf with font support for diverse character sets
- Migrate fileHandler to support new font loading workflow
- Update main.ts and ui.ts to integrate new type system and font utilities
- Update form-creator.html page with enhanced font configuration UI
---
index.html | 2 +-
package-lock.json | 14 +-
package.json | 1 +
src/css/styles.css | 14 --
src/js/config/font-mappings.ts | 189 ++++++++++++++++
src/js/handlers/fileHandler.ts | 8 +-
src/js/logic/form-creator.ts | 235 +++++++++++++++-----
src/js/logic/ocr-pdf.ts | 148 +++++++++----
src/js/logic/txt-to-pdf.ts | 388 ++++++++++++++++++++++++---------
src/js/main.ts | 51 +++--
src/js/types/form-creator.ts | 40 ++++
src/js/types/index.ts | 2 +
src/js/types/ocr.ts | 10 +
src/js/ui.ts | 57 ++++-
src/js/utils/font-loader.ts | 281 ++++++++++++++++++++++++
src/pages/form-creator.html | 21 ++
16 files changed, 1220 insertions(+), 241 deletions(-)
create mode 100644 src/js/config/font-mappings.ts
create mode 100644 src/js/types/form-creator.ts
create mode 100644 src/js/types/index.ts
create mode 100644 src/js/types/ocr.ts
create mode 100644 src/js/utils/font-loader.ts
diff --git a/index.html b/index.html
index f63bbc8..3dcd988 100644
--- a/index.html
+++ b/index.html
@@ -280,7 +280,7 @@
diff --git a/package-lock.json b/package-lock.json
index ac62519..5721625 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bento-pdf",
- "version": "1.7.3",
+ "version": "1.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bento-pdf",
- "version": "1.7.3",
+ "version": "1.10.0",
"license": "Apache-2.0",
"dependencies": {
"@fontsource/cedarville-cursive": "^5.2.7",
@@ -17,6 +17,7 @@
"@fontsource/lato": "^5.2.7",
"@fontsource/merriweather": "^5.2.11",
"@neslinesli93/qpdf-wasm": "^0.3.0",
+ "@pdf-lib/fontkit": "^1.1.1",
"@tailwindcss/vite": "^4.1.15",
"archiver": "^7.0.1",
"blob-stream": "^0.1.3",
@@ -3088,6 +3089,15 @@
"node": ">= 8"
}
},
+ "node_modules/@pdf-lib/fontkit": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
+ "integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^1.0.6"
+ }
+ },
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
diff --git a/package.json b/package.json
index 37e6cd1..89bbb90 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"@fontsource/lato": "^5.2.7",
"@fontsource/merriweather": "^5.2.11",
"@neslinesli93/qpdf-wasm": "^0.3.0",
+ "@pdf-lib/fontkit": "^1.1.1",
"@tailwindcss/vite": "^4.1.15",
"archiver": "^7.0.1",
"blob-stream": "^0.1.3",
diff --git a/src/css/styles.css b/src/css/styles.css
index f8de956..cb8ee6b 100644
--- a/src/css/styles.css
+++ b/src/css/styles.css
@@ -568,20 +568,6 @@ button:disabled,
background: #6b7280;
}
-/* Hide elements on touch devices */
-@media (pointer: coarse) {
- .hidden-on-touch {
- display: none !important;
- }
-}
-
-/* Also hide on very small screens just in case */
-@media (max-width: 640px) {
- .hidden-on-touch {
- display: none !important;
- }
-}
-
/* Scroll to Top Button Visibility */
#scroll-to-top-btn.visible {
opacity: 1;
diff --git a/src/js/config/font-mappings.ts b/src/js/config/font-mappings.ts
new file mode 100644
index 0000000..c6c0c31
--- /dev/null
+++ b/src/js/config/font-mappings.ts
@@ -0,0 +1,189 @@
+/**
+ * Font mappings for OCR text layer rendering
+ * Maps Tesseract language codes to appropriate Noto Sans font families and their CDN URLs
+ */
+
+export const languageToFontFamily: Record = {
+ // CJK Languages
+ jpn: 'Noto Sans JP',
+ chi_sim: 'Noto Sans SC',
+ chi_tra: 'Noto Sans TC',
+ kor: 'Noto Sans KR',
+
+ // Arabic Script
+ ara: 'Noto Sans Arabic',
+ fas: 'Noto Sans Arabic',
+ urd: 'Noto Sans Arabic',
+ pus: 'Noto Sans Arabic',
+ kur: 'Noto Sans Arabic',
+
+ // Devanagari Script
+ hin: 'Noto Sans Devanagari',
+ mar: 'Noto Sans Devanagari',
+ san: 'Noto Sans Devanagari',
+ nep: 'Noto Sans Devanagari',
+
+ // Bengali Script
+ ben: 'Noto Sans Bengali',
+ asm: 'Noto Sans Bengali',
+
+ // Tamil Script
+ tam: 'Noto Sans Tamil',
+
+ // Telugu Script
+ tel: 'Noto Sans Telugu',
+
+ // Kannada Script
+ kan: 'Noto Sans Kannada',
+
+ // Malayalam Script
+ mal: 'Noto Sans Malayalam',
+
+ // Gujarati Script
+ guj: 'Noto Sans Gujarati',
+
+ // Gurmukhi Script (Punjabi)
+ pan: 'Noto Sans Gurmukhi',
+
+ // Oriya Script
+ ori: 'Noto Sans Oriya',
+
+ // Sinhala Script
+ sin: 'Noto Sans Sinhala',
+
+ // Thai Script
+ tha: 'Noto Sans Thai',
+
+ // Lao Script
+ lao: 'Noto Sans Lao',
+
+ // Khmer Script
+ khm: 'Noto Sans Khmer',
+
+ // Myanmar Script
+ mya: 'Noto Sans Myanmar',
+
+ // Tibetan Script
+ bod: 'Noto Serif Tibetan',
+
+ // Georgian Script
+ kat: 'Noto Sans Georgian',
+ kat_old: 'Noto Sans Georgian',
+
+ // Armenian Script
+ hye: 'Noto Sans Armenian',
+
+ // Hebrew Script
+ heb: 'Noto Sans Hebrew',
+ yid: 'Noto Sans Hebrew',
+
+ // Ethiopic Script
+ amh: 'Noto Sans Ethiopic',
+ tir: 'Noto Sans Ethiopic',
+
+ // Cherokee Script
+ chr: 'Noto Sans Cherokee',
+
+ // Syriac Script
+ syr: 'Noto Sans Syriac',
+
+ // Cyrillic Script (Noto Sans includes Cyrillic)
+ bel: 'Noto Sans',
+ bul: 'Noto Sans',
+ mkd: 'Noto Sans',
+ rus: 'Noto Sans',
+ srp: 'Noto Sans',
+ srp_latn: 'Noto Sans',
+ ukr: 'Noto Sans',
+ kaz: 'Noto Sans',
+ kir: 'Noto Sans',
+ tgk: 'Noto Sans',
+ uzb: 'Noto Sans',
+ uzb_cyrl: 'Noto Sans',
+ aze_cyrl: 'Noto Sans',
+
+ // Latin Script (covered by base Noto Sans)
+ afr: 'Noto Sans',
+ aze: 'Noto Sans',
+ bos: 'Noto Sans',
+ cat: 'Noto Sans',
+ ceb: 'Noto Sans',
+ ces: 'Noto Sans',
+ cym: 'Noto Sans',
+ dan: 'Noto Sans',
+ deu: 'Noto Sans',
+ ell: 'Noto Sans',
+ eng: 'Noto Sans',
+ enm: 'Noto Sans',
+ epo: 'Noto Sans',
+ est: 'Noto Sans',
+ eus: 'Noto Sans',
+ fin: 'Noto Sans',
+ fra: 'Noto Sans',
+ frk: 'Noto Sans',
+ frm: 'Noto Sans',
+ gle: 'Noto Sans',
+ glg: 'Noto Sans',
+ grc: 'Noto Sans',
+ hat: 'Noto Sans',
+ hrv: 'Noto Sans',
+ hun: 'Noto Sans',
+ iku: 'Noto Sans',
+ ind: 'Noto Sans',
+ isl: 'Noto Sans',
+ ita: 'Noto Sans',
+ ita_old: 'Noto Sans',
+ jav: 'Noto Sans',
+ lat: 'Noto Sans',
+ lav: 'Noto Sans',
+ lit: 'Noto Sans',
+ mlt: 'Noto Sans',
+ msa: 'Noto Sans',
+ nld: 'Noto Sans',
+ nor: 'Noto Sans',
+ pol: 'Noto Sans',
+ por: 'Noto Sans',
+ ron: 'Noto Sans',
+ slk: 'Noto Sans',
+ slv: 'Noto Sans',
+ spa: 'Noto Sans',
+ spa_old: 'Noto Sans',
+ sqi: 'Noto Sans',
+ swa: 'Noto Sans',
+ swe: 'Noto Sans',
+ tgl: 'Noto Sans',
+ tur: 'Noto Sans',
+ vie: 'Noto Sans',
+ dzo: 'Noto Sans',
+ uig: 'Noto Sans',
+};
+
+export const fontFamilyToUrl: Record = {
+ 'Noto Sans JP': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Japanese/NotoSansCJKjp-Regular.otf',
+ 'Noto Sans SC': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf',
+ 'Noto Sans TC': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/TraditionalChinese/NotoSansCJKtc-Regular.otf',
+ 'Noto Sans KR': 'https://raw.githack.com/googlefonts/noto-cjk/main/Sans/OTF/Korean/NotoSansCJKkr-Regular.otf',
+ 'Noto Sans Arabic': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArabic/NotoSansArabic-Regular.ttf',
+ 'Noto Sans Devanagari': 'https://raw.githack.com/googlefonts/noto-fonts/main/unhinted/ttf/NotoSansDevanagari/NotoSansDevanagari-Regular.ttf',
+ 'Noto Sans Bengali': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansBengali/NotoSansBengali-Regular.ttf',
+ 'Noto Sans Gujarati': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGujarati/NotoSansGujarati-Regular.ttf',
+ 'Noto Sans Kannada': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKannada/NotoSansKannada-Regular.ttf',
+ 'Noto Sans Malayalam': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMalayalam/NotoSansMalayalam-Regular.ttf',
+ 'Noto Sans Oriya': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansOriya/NotoSansOriya-Regular.ttf',
+ 'Noto Sans Gurmukhi': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGurmukhi/NotoSansGurmukhi-Regular.ttf',
+ 'Noto Sans Tamil': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTamil/NotoSansTamil-Regular.ttf',
+ 'Noto Sans Telugu': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansTelugu/NotoSansTelugu-Regular.ttf',
+ 'Noto Sans Sinhala': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSinhala/NotoSansSinhala-Regular.ttf',
+ 'Noto Sans Thai': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansThai/NotoSansThai-Regular.ttf',
+ 'Noto Sans Khmer': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansKhmer/NotoSansKhmer-Regular.ttf',
+ 'Noto Sans Lao': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansLao/NotoSansLao-Regular.ttf',
+ 'Noto Sans Myanmar': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansMyanmar/NotoSansMyanmar-Regular.ttf',
+ 'Noto Sans Hebrew': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansHebrew/NotoSansHebrew-Regular.ttf',
+ 'Noto Sans Georgian': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansGeorgian/NotoSansGeorgian-Regular.ttf',
+ 'Noto Sans Ethiopic': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansEthiopic/NotoSansEthiopic-Regular.ttf',
+ 'Noto Serif Tibetan': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSerifTibetan/NotoSerifTibetan-Regular.ttf',
+ 'Noto Sans Cherokee': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansCherokee/NotoSansCherokee-Regular.ttf',
+ 'Noto Sans Armenian': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansArmenian/NotoSansArmenian-Regular.ttf',
+ 'Noto Sans Syriac': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSansSyriac/NotoSansSyriac-Regular.ttf',
+ 'Noto Sans': 'https://raw.githack.com/googlefonts/noto-fonts/main/hinted/ttf/NotoSans/NotoSans-Regular.ttf',
+};
\ No newline at end of file
diff --git a/src/js/handlers/fileHandler.ts b/src/js/handlers/fileHandler.ts
index 445a1aa..34fef03 100644
--- a/src/js/handlers/fileHandler.ts
+++ b/src/js/handlers/fileHandler.ts
@@ -473,24 +473,24 @@ async function handleSinglePdfUpload(toolId, file) {
addBtn.onclick = () => {
const fieldWrapper = document.createElement('div');
- fieldWrapper.className = 'flex items-center gap-2 custom-field-wrapper';
+ fieldWrapper.className = 'flex flex-col sm:flex-row items-stretch sm:items-center gap-2 custom-field-wrapper';
const keyInput = document.createElement('input');
keyInput.type = 'text';
keyInput.placeholder = 'Key (e.g., Department)';
keyInput.className =
- 'custom-meta-key w-1/3 bg-gray-800 border border-gray-600 text-white rounded-lg p-2';
+ 'custom-meta-key w-full sm:w-1/3 bg-gray-800 border border-gray-600 text-white rounded-lg p-2';
const valueInput = document.createElement('input');
valueInput.type = 'text';
valueInput.placeholder = 'Value (e.g., Marketing)';
valueInput.className =
- 'custom-meta-value flex-grow bg-gray-800 border border-gray-600 text-white rounded-lg p-2';
+ 'custom-meta-value w-full sm:flex-grow bg-gray-800 border border-gray-600 text-white rounded-lg p-2';
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className =
- 'btn p-2 text-red-500 hover:bg-gray-700 rounded-full';
+ 'btn p-2 text-red-500 hover:bg-gray-700 rounded-full self-center sm:self-auto';
removeBtn.innerHTML = ' ';
removeBtn.addEventListener('click', () => fieldWrapper.remove());
diff --git a/src/js/logic/form-creator.ts b/src/js/logic/form-creator.ts
index c7d5629..db5808c 100644
--- a/src/js/logic/form-creator.ts
+++ b/src/js/logic/form-creator.ts
@@ -8,50 +8,14 @@ import 'pdfjs-dist/web/pdf_viewer.css'
// Initialize PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()
-interface FormField {
- id: string
- type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'optionlist' | 'button' | 'signature' | 'date' | 'image'
- x: number
- y: number
- width: number
- height: number
- name: string
- defaultValue: string
- fontSize: number
- alignment: 'left' | 'center' | 'right'
- textColor: string
- required: boolean
- readOnly: boolean
- tooltip: string
- combCells: number
- maxLength: number
- options?: string[]
- checked?: boolean
- exportValue?: string
- groupName?: string
- label?: string
- pageIndex: number
- action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide'
- actionUrl?: string
- jsScript?: string
- targetFieldName?: string
- visibilityAction?: 'show' | 'hide' | 'toggle'
- dateFormat?: string
- multiline?: boolean
-}
+import { FormField, PageData } from '../types/index.js'
-interface PageData {
- index: number
- width: number
- height: number
- pdfPageData?: string
-}
let fields: FormField[] = []
let selectedField: FormField | null = null
let fieldCounter = 0
-let existingFieldNames: Set = new Set()
-let existingRadioGroups: Set = new Set()
+let existingFieldNames: Set = new Set()
+let existingRadioGroups: Set = new Set()
let draggedElement: HTMLElement | null = null
let offsetX = 0
let offsetY = 0
@@ -59,7 +23,7 @@ let offsetY = 0
let pages: PageData[] = []
let currentPageIndex = 0
let uploadedPdfDoc: PDFDocument | null = null
-let uploadedPdfjsDoc: any = null
+let uploadedPdfjsDoc: any = null
let pageSize: { width: number; height: number } = { width: 612, height: 792 }
let currentScale = 1.333
let pdfViewerOffset = { x: 0, y: 0 }
@@ -99,6 +63,132 @@ const addPageBtn = document.getElementById('addPageBtn') as HTMLButtonElement
const resetBtn = document.getElementById('resetBtn') as HTMLButtonElement
const downloadBtn = document.getElementById('downloadBtn') as HTMLButtonElement
const backToToolsBtn = document.getElementById('back-to-tools') as HTMLButtonElement | null
+const gotoPageInput = document.getElementById('gotoPageInput') as HTMLInputElement
+const gotoPageBtn = document.getElementById('gotoPageBtn') as HTMLButtonElement
+
+const gridVInput = document.getElementById('gridVInput') as HTMLInputElement
+const gridHInput = document.getElementById('gridHInput') as HTMLInputElement
+const toggleGridBtn = document.getElementById('toggleGridBtn') as HTMLButtonElement
+const enableGridCheckbox = document.getElementById('enableGridCheckbox') as HTMLInputElement
+let gridV = 2
+let gridH = 2
+let gridAlwaysVisible = false
+let gridEnabled = true
+
+if (gridVInput && gridHInput) {
+ gridVInput.value = '2'
+ gridHInput.value = '2'
+
+ const updateGrid = () => {
+ let v = parseInt(gridVInput.value) || 2
+ let h = parseInt(gridHInput.value) || 2
+
+ if (v < 2) { v = 2; gridVInput.value = '2' }
+ if (h < 2) { h = 2; gridHInput.value = '2' }
+ if (v > 14) { v = 14; gridVInput.value = '14' }
+ if (h > 14) { h = 14; gridHInput.value = '14' }
+
+ gridV = v
+ gridH = h
+
+ if (gridAlwaysVisible && gridEnabled) {
+ renderGrid()
+ }
+ }
+
+ gridVInput.addEventListener('input', updateGrid)
+ gridHInput.addEventListener('input', updateGrid)
+}
+
+if (enableGridCheckbox) {
+ enableGridCheckbox.addEventListener('change', (e) => {
+ gridEnabled = (e.target as HTMLInputElement).checked
+
+ if (!gridEnabled) {
+ removeGrid()
+ if (gridVInput) gridVInput.disabled = true
+ if (gridHInput) gridHInput.disabled = true
+ if (toggleGridBtn) toggleGridBtn.disabled = true
+ } else {
+ if (gridVInput) gridVInput.disabled = false
+ if (gridHInput) gridHInput.disabled = false
+ if (toggleGridBtn) toggleGridBtn.disabled = false
+ if (gridAlwaysVisible) renderGrid()
+ }
+ })
+}
+
+if (toggleGridBtn) {
+ toggleGridBtn.addEventListener('click', () => {
+ gridAlwaysVisible = !gridAlwaysVisible
+
+ if (gridAlwaysVisible) {
+ toggleGridBtn.classList.add('bg-indigo-600')
+ toggleGridBtn.classList.remove('bg-gray-600')
+ if (gridEnabled) renderGrid()
+ } else {
+ toggleGridBtn.classList.remove('bg-indigo-600')
+ toggleGridBtn.classList.add('bg-gray-600')
+ removeGrid()
+ }
+ })
+}
+
+function renderGrid() {
+ const existingGrid = document.getElementById('pdfGrid')
+ if (existingGrid) existingGrid.remove()
+
+ const gridContainer = document.createElement('div')
+ gridContainer.id = 'pdfGrid'
+ gridContainer.className = 'absolute inset-0 pointer-events-none'
+ gridContainer.style.zIndex = '1'
+
+ if (gridV > 0) {
+ const stepX = canvas.offsetWidth / gridV
+ for (let i = 0; i <= gridV; i++) {
+ const line = document.createElement('div')
+ line.className = 'absolute top-0 bottom-0 border-l-2 border-indigo-500 opacity-60'
+ line.style.left = (i * stepX) + 'px'
+ gridContainer.appendChild(line)
+ }
+ }
+
+ if (gridH > 0) {
+ const stepY = canvas.offsetHeight / gridH
+ for (let i = 0; i <= gridH; i++) {
+ const line = document.createElement('div')
+ line.className = 'absolute left-0 right-0 border-t-2 border-indigo-500 opacity-60'
+ line.style.top = (i * stepY) + 'px'
+ gridContainer.appendChild(line)
+ }
+ }
+
+ canvas.insertBefore(gridContainer, canvas.firstChild)
+}
+
+function removeGrid() {
+ const existingGrid = document.getElementById('pdfGrid')
+ if (existingGrid) existingGrid.remove()
+}
+
+if (gotoPageBtn && gotoPageInput) {
+ gotoPageBtn.addEventListener('click', () => {
+ const pageNum = parseInt(gotoPageInput.value)
+ if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= pages.length) {
+ currentPageIndex = pageNum - 1
+ renderCanvas()
+ updatePageNavigation()
+ } else {
+ alert(`Please enter a valid page number between 1 and ${pages.length}`)
+ }
+ })
+
+ gotoPageInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ gotoPageBtn.click()
+ }
+ })
+}
// Tool item interactions
const toolItems = document.querySelectorAll('.tool-item')
@@ -109,10 +199,13 @@ toolItems.forEach(item => {
e.dataTransfer.effectAllowed = 'copy'
const type = (item as HTMLElement).dataset.type || 'text'
e.dataTransfer.setData('text/plain', type)
+ if (gridEnabled) renderGrid()
}
})
- // Click to select tool for placement
+ item.addEventListener('dragend', () => {
+ if (!gridAlwaysVisible && gridEnabled) removeGrid()
+ })
item.addEventListener('click', () => {
const type = (item as HTMLElement).dataset.type || 'text'
@@ -191,8 +284,9 @@ canvas.addEventListener('dragover', (e) => {
canvas.addEventListener('drop', (e) => {
e.preventDefault()
+ if (!gridAlwaysVisible) removeGrid()
const rect = canvas.getBoundingClientRect()
- const x = e.clientX - rect.left - 75 // Center the field on drop point
+ const x = e.clientX - rect.left - 75
const y = e.clientY - rect.top - 15
const type = e.dataTransfer?.getData('text/plain') || 'text'
createField(type as any, x, y)
@@ -246,7 +340,9 @@ function createField(type: FormField['type'], x: number, y: number): void {
visibilityAction: type === 'button' ? 'toggle' : undefined,
dateFormat: type === 'date' ? 'mm/dd/yyyy' : undefined,
pageIndex: currentPageIndex,
- multiline: type === 'text' ? false : undefined
+ multiline: type === 'text' ? false : undefined,
+ borderColor: '#000000',
+ hideBorder: false
}
fields.push(field)
@@ -263,6 +359,7 @@ function renderField(field: FormField): void {
fieldWrapper.style.top = field.y + 'px'
fieldWrapper.style.width = field.width + 'px'
fieldWrapper.style.overflow = 'visible'
+ fieldWrapper.style.zIndex = '10' // Ensure fields are above grid and PDF
// Create label - hidden by default, shown on group hover or selection
const label = document.createElement('div')
@@ -398,6 +495,7 @@ function renderField(field: FormField): void {
offsetX = e.clientX - rect.left - field.x
offsetY = e.clientY - rect.top - field.y
selectField(field)
+ if (gridEnabled) renderGrid()
e.preventDefault()
})
@@ -559,9 +657,9 @@ document.addEventListener('mouseup', () => {
draggedElement = null
resizing = false
resizeField = null
+ if (!gridAlwaysVisible) removeGrid()
})
-// Touch move for dragging and resizing
document.addEventListener('touchmove', (e) => {
const touch = e.touches[0]
if (resizing && resizeField) {
@@ -866,6 +964,14 @@ function showProperties(field: FormField): void {
Read Only
+
+ Border Color
+
+
+
+
+ Hide Border
+
Delete Field
@@ -963,6 +1069,17 @@ function showProperties(field: FormField): void {
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)
})
@@ -1424,14 +1541,15 @@ downloadBtn.addEventListener('click', async () => {
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: 1,
- borderColor: rgb(0, 0, 0),
+ 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),
})
@@ -1474,13 +1592,14 @@ downloadBtn.addEventListener('click', async () => {
} 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: 1,
- borderColor: rgb(0, 0, 0),
+ borderWidth: field.hideBorder ? 0 : 1,
+ borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
backgroundColor: rgb(1, 1, 1),
})
if (field.checked) checkBox.check()
@@ -1512,13 +1631,14 @@ downloadBtn.addEventListener('click', async () => {
}
}
+ const borderRgb = hexToRgb(field.borderColor || '#000000')
radioGroup.addOptionToPage(field.exportValue || 'Yes', pdfPage as any, {
x: x,
y: y,
width: width,
height: height,
- borderWidth: 1,
- borderColor: rgb(0, 0, 0),
+ 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')
@@ -1532,13 +1652,14 @@ downloadBtn.addEventListener('click', async () => {
} 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: 1,
- borderColor: rgb(0, 0, 0),
+ 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)
@@ -1561,13 +1682,14 @@ downloadBtn.addEventListener('click', async () => {
} 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: 1,
- borderColor: rgb(0, 0, 0),
+ 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)
@@ -1590,13 +1712,14 @@ downloadBtn.addEventListener('click', async () => {
} 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: 1,
- borderColor: rgb(0, 0, 0),
+ borderWidth: field.hideBorder ? 0 : 1,
+ borderColor: rgb(borderRgb.r, borderRgb.g, borderRgb.b),
backgroundColor: rgb(0.8, 0.8, 0.8), // Light gray
})
diff --git a/src/js/logic/ocr-pdf.ts b/src/js/logic/ocr-pdf.ts
index 303b09f..d77fcbd 100644
--- a/src/js/logic/ocr-pdf.ts
+++ b/src/js/logic/ocr-pdf.ts
@@ -4,20 +4,25 @@ import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/he
import { state } from '../state.js';
import Tesseract from 'tesseract.js';
import { PDFDocument as PDFLibDocument, StandardFonts, rgb } from 'pdf-lib';
+import fontkit from '@pdf-lib/fontkit';
import { icons, createIcons } from 'lucide';
import * as pdfjsLib from 'pdfjs-dist';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
+import type { Word } from '../types/index.js';
-let searchablePdfBytes: any = null;
+let searchablePdfBytes: Uint8Array | null = null;
-function sanitizeTextForWinAnsi(text: string): string {
- // Remove invisible Unicode control characters (like Left-to-Right Mark U+200E)
- return text
- .replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '')
- .replace(/[^\u0020-\u007E\u00A0-\u00FF]/g, '');
-}
+import { getFontForLanguage } from '../utils/font-loader.js';
+
+
+// function sanitizeTextForWinAnsi(text: string): string {
+// // Remove invisible Unicode control characters (like Left-to-Right Mark U+200E)
+// return text
+// .replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '')
+// .replace(/[^\u0020-\u007E\u00A0-\u00FF]/g, '');
+// }
function parseHOCR(hocrText: string) {
const parser = new DOMParser();
@@ -55,7 +60,7 @@ function parseHOCR(hocrText: string) {
return words;
}
-function binarizeCanvas(ctx: any) {
+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) {
@@ -68,7 +73,7 @@ function binarizeCanvas(ctx: any) {
ctx.putImageData(imageData, 0, 0);
}
-function updateProgress(status: any, progress: any) {
+function updateProgress(status: string, progress: number) {
const progressBar = document.getElementById('progress-bar');
const progressStatus = document.getElementById('progress-status');
const progressLog = document.getElementById('progress-log');
@@ -88,12 +93,13 @@ async function runOCR() {
const selectedLangs = Array.from(
document.querySelectorAll('.lang-checkbox:checked')
).map((cb) => (cb as HTMLInputElement).value);
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- const scale = parseFloat(document.getElementById('ocr-resolution').value);
- // @ts-expect-error TS(2339) FIXME: Property 'checked' does not exist on type 'HTMLEle... Remove this comment to see the full error message
- const binarize = document.getElementById('ocr-binarize').checked;
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- const whitelist = document.getElementById('ocr-whitelist').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(
@@ -109,12 +115,13 @@ async function runOCR() {
try {
const worker = await Tesseract.createWorker(langString, 1, {
- logger: (m: any) => updateProgress(m.status, m.progress || 0),
+ logger: (m: { status: string; progress: number }) =>
+ updateProgress(m.status, m.progress || 0),
});
- // Enable hOCR output
await worker.setParameters({
tessjs_create_hocr: '1',
+ tessedit_pageseg_mode: Tesseract.PSM.AUTO,
});
await worker.setParameters({
@@ -125,7 +132,48 @@ async function runOCR() {
await readFileAsArrayBuffer(state.files[0])
).promise;
const newPdfDoc = await PDFLibDocument.create();
- const font = await newPdfDoc.embedFont(StandardFonts.Helvetica);
+
+ newPdfDoc.registerFontkit(fontkit);
+
+ updateProgress('Loading fonts...', 0);
+
+ // Prioritize non-Latin languages for font selection if multiple are selected
+ 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(l => priorityLangs.includes(l)) || selectedLangs[0] || 'eng';
+
+ const hasCJK = selectedLangs.some(l => cjkLangs.includes(l));
+ const hasIndic = selectedLangs.some(l => indicLangs.includes(l));
+ const hasLatin = selectedLangs.some(l => !priorityLangs.includes(l)) || selectedLangs.includes('eng');
+ const isIndicPlusLatin = hasIndic && hasLatin && !hasCJK;
+
+ let primaryFont;
+ let latinFont;
+
+ try {
+ let fontBytes;
+ 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 {
+ // For CJK or single-script, use one font
+ 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++) {
@@ -135,10 +183,12 @@ async function runOCR() {
);
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) {
@@ -155,8 +205,8 @@ async function runOCR() {
const pngImageBytes = await new Promise((resolve) =>
canvas.toBlob((blob) => {
const reader = new FileReader();
- // @ts-expect-error TS(2769) FIXME: No overload matches this call.
- reader.onload = () => resolve(new Uint8Array(reader.result));
+ reader.onload = () =>
+ resolve(new Uint8Array(reader.result as ArrayBuffer));
reader.readAsArrayBuffer(blob);
}, 'image/png')
);
@@ -172,22 +222,37 @@ async function runOCR() {
if (data.hocr) {
const words = parseHOCR(data.hocr);
- words.forEach((word: any) => {
+ words.forEach((word: Word) => {
const { x0, y0, x1, y1 } = word.bbox;
- // Sanitize the text to remove characters WinAnsi cannot encode
- const text = sanitizeTextForWinAnsi(word.text);
+ const text = word.text.replace(/[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\uFEFF]/g, '');
- // Skip words that become empty after sanitization
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;
- let textWidth = font.widthOfTextAtSize(text, fontSize);
- while (textWidth > bboxWidth && fontSize > 1) {
- fontSize -= 0.5;
- textWidth = font.widthOfTextAtSize(text, fontSize);
+ 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 {
@@ -200,12 +265,12 @@ async function runOCR() {
opacity: 0,
});
} catch (error) {
- // If drawing fails despite sanitization, log and skip this word
console.warn(`Could not draw text "${text}":`, error);
}
});
}
+
fullText += data.text + '\n\n';
}
@@ -216,23 +281,27 @@ async function runOCR() {
document.getElementById('ocr-results').classList.remove('hidden');
createIcons({ icons });
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- document.getElementById('ocr-text-output').value = fullText.trim();
+ (
+ document.getElementById('ocr-text-output') as HTMLTextAreaElement
+ ).value = fullText.trim();
document
.getElementById('download-searchable-pdf')
.addEventListener('click', () => {
- downloadFile(
- new Blob([searchablePdfBytes], { type: 'application/pdf' }),
- 'searchable.pdf'
- );
+ if (searchablePdfBytes) {
+ downloadFile(
+ new Blob([searchablePdfBytes as BlobPart], { type: 'application/pdf' }),
+ 'searchable.pdf'
+ );
+ }
});
// CHANGE: The copy button logic is updated to be safer.
document.getElementById('copy-text-btn').addEventListener('click', (e) => {
const button = e.currentTarget as HTMLButtonElement;
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme...
- const textToCopy = document.getElementById('ocr-text-output').value;
+ const textToCopy = (
+ document.getElementById('ocr-text-output') as HTMLTextAreaElement
+ ).value;
navigator.clipboard.writeText(textToCopy).then(() => {
button.textContent = ''; // Clear the button safely
@@ -259,8 +328,9 @@ async function runOCR() {
document
.getElementById('download-txt-btn')
.addEventListener('click', () => {
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- const textToSave = document.getElementById('ocr-text-output').value;
+ const textToSave = (
+ document.getElementById('ocr-text-output') as HTMLTextAreaElement
+ ).value;
const blob = new Blob([textToSave], { type: 'text/plain' });
downloadFile(blob, 'ocr-text.txt');
});
diff --git a/src/js/logic/txt-to-pdf.ts b/src/js/logic/txt-to-pdf.ts
index 5245335..299cc55 100644
--- a/src/js/logic/txt-to-pdf.ts
+++ b/src/js/logic/txt-to-pdf.ts
@@ -2,6 +2,9 @@ import { showLoader, hideLoader, showAlert } from '../ui.js';
import { downloadFile, hexToRgb } from '../utils/helpers.js';
import { state } from '../state.js';
import JSZip from 'jszip';
+import { getFontForLanguage, getLanguageForChar } from '../utils/font-loader.js';
+import { languageToFontFamily } from '../config/font-mappings.js';
+import fontkit from '@pdf-lib/fontkit';
import {
PDFDocument as PDFLibDocument,
@@ -10,69 +13,46 @@ import {
PageSizes,
} from 'pdf-lib';
-function sanitizeTextForPdf(text: string): string {
- return text
- .split('')
- .map((char) => {
- const code = char.charCodeAt(0);
-
- if (code === 0x20 || code === 0x09 || code === 0x0A) {
- return char;
- }
-
- if ((code >= 0x00 && code <= 0x1F) || (code >= 0x7F && code <= 0x9F)) {
- return ' ';
- }
-
- if (code < 0x20 || (code > 0x7E && code < 0xA0)) {
- return ' ';
- }
-
- const replacements: { [key: number]: string } = {
- 0x2018: "'",
- 0x2019: "'",
- 0x201C: '"',
- 0x201D: '"',
- 0x2013: '-',
- 0x2014: '--',
- 0x2026: '...',
- 0x00A0: ' ',
- };
-
- if (replacements[code]) {
- return replacements[code];
- }
-
- try {
- if (code <= 0xFF) {
- return char;
- }
- return '?';
- } catch {
- return '?';
- }
- })
- .join('')
- .replace(/[ \t]+/g, ' ')
- .replace(/\r\n/g, '\n')
- .replace(/\r/g, '\n')
- .split('\n')
- .map((line) => line.trimEnd())
- .join('\n');
-}
-
async function createPdfFromText(
text: string,
- fontFamilyKey: string,
+ selectedLanguages: string[],
fontSize: number,
pageSizeKey: string,
- colorHex: string
+ colorHex: string,
+ orientation: string,
+ customWidth?: number,
+ customHeight?: number
): Promise {
- const sanitizedText = sanitizeTextForPdf(text);
-
const pdfDoc = await PDFLibDocument.create();
- const font = await pdfDoc.embedFont(StandardFonts[fontFamilyKey]);
- const pageSize = PageSizes[pageSizeKey];
+ pdfDoc.registerFontkit(fontkit);
+
+ console.log(`User selected languages: ${selectedLanguages.join(', ')}`);
+
+ const fontMap = new Map();
+ const fallbackFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
+
+ if (!selectedLanguages.includes('eng')) {
+ selectedLanguages.push('eng');
+ }
+
+ for (const lang of selectedLanguages) {
+ try {
+ const fontBytes = await getFontForLanguage(lang);
+ const font = await pdfDoc.embedFont(fontBytes, { subset: false });
+ fontMap.set(lang, font);
+ } catch (e) {
+ console.warn(`Failed to load font for ${lang}, using fallback`, e);
+ fontMap.set(lang, fallbackFont);
+ }
+ }
+
+ let pageSize = pageSizeKey === 'Custom'
+ ? [customWidth || 595, customHeight || 842] as [number, number]
+ : PageSizes[pageSizeKey];
+
+ if (orientation === 'landscape') {
+ pageSize = [pageSize[1], pageSize[0]] as [number, number];
+ }
const margin = 72;
const textColor = hexToRgb(colorHex);
@@ -82,43 +62,130 @@ async function createPdfFromText(
const lineHeight = fontSize * 1.3;
let y = height - margin;
- const paragraphs = sanitizedText.split('\n');
+ const paragraphs = text.split('\n');
+
for (const paragraph of paragraphs) {
+ if (paragraph.trim() === '') {
+ y -= lineHeight;
+ if (y < margin) {
+ page = pdfDoc.addPage(pageSize);
+ y = page.getHeight() - margin;
+ }
+ continue;
+ }
+
const words = paragraph.split(' ');
- let currentLine = '';
+ let currentLineWords: { text: string; font: any }[] = [];
+ let currentLineWidth = 0;
+
for (const word of words) {
- const testLine =
- currentLine.length > 0 ? `${currentLine} ${word}` : word;
- if (font.widthOfTextAtSize(testLine, fontSize) <= textWidth) {
- currentLine = testLine;
+ let wordLang = 'eng';
+
+ for (const char of word) {
+ const charLang = getLanguageForChar(char);
+
+ if (charLang === 'chi_sim') {
+ if (selectedLanguages.includes('jpn')) wordLang = 'jpn';
+ else if (selectedLanguages.includes('kor')) wordLang = 'kor';
+ else if (selectedLanguages.includes('chi_tra')) wordLang = 'chi_tra';
+ else if (selectedLanguages.includes('chi_sim')) wordLang = 'chi_sim';
+ } else if (selectedLanguages.includes(charLang)) {
+ wordLang = charLang;
+ }
+
+ if (wordLang !== 'eng') break;
+ }
+
+ const font = fontMap.get(wordLang) || fontMap.get('eng') || fallbackFont;
+
+ let wordWidth = 0;
+ try {
+ wordWidth = font.widthOfTextAtSize(word, fontSize);
+ } catch (e) {
+ console.warn(`Width calculation failed for "${word}"`, e);
+ wordWidth = word.length * fontSize * 0.5;
+ }
+
+ let spaceWidth = 0;
+ if (currentLineWords.length > 0) {
+ try {
+ spaceWidth = font.widthOfTextAtSize(' ', fontSize);
+ } catch {
+ spaceWidth = fontSize * 0.25;
+ }
+ }
+
+ if (currentLineWidth + spaceWidth + wordWidth <= textWidth) {
+ currentLineWords.push({ text: word, font });
+ currentLineWidth += spaceWidth + wordWidth;
} else {
+ // Draw current line
if (y < margin + lineHeight) {
page = pdfDoc.addPage(pageSize);
y = page.getHeight() - margin;
}
- page.drawText(currentLine, {
- x: margin,
- y,
- font,
- size: fontSize,
- color: rgb(textColor.r, textColor.g, textColor.b),
- });
+
+ let currentX = margin;
+ for (let i = 0; i < currentLineWords.length; i++) {
+ const w = currentLineWords[i];
+ try {
+ page.drawText(w.text, {
+ x: currentX,
+ y,
+ font: w.font,
+ size: fontSize,
+ color: rgb(textColor.r, textColor.g, textColor.b),
+ });
+
+ const wWidth = w.font.widthOfTextAtSize(w.text, fontSize);
+ currentX += wWidth;
+
+ if (i < currentLineWords.length - 1) {
+ const sWidth = w.font.widthOfTextAtSize(' ', fontSize);
+ currentX += sWidth;
+ }
+ } catch (e) {
+ console.warn(`Failed to draw word: "${w.text}"`, e);
+ }
+ }
+
y -= lineHeight;
- currentLine = word;
+
+ currentLineWords = [{ text: word, font }];
+ currentLineWidth = wordWidth;
}
}
- if (currentLine.length > 0) {
+
+ if (currentLineWords.length > 0) {
if (y < margin + lineHeight) {
page = pdfDoc.addPage(pageSize);
y = page.getHeight() - margin;
}
- page.drawText(currentLine, {
- x: margin,
- y,
- font,
- size: fontSize,
- color: rgb(textColor.r, textColor.g, textColor.b),
- });
+
+ let currentX = margin;
+ for (let i = 0; i < currentLineWords.length; i++) {
+ const w = currentLineWords[i];
+ try {
+ page.drawText(w.text, {
+ x: currentX,
+ y,
+ font: w.font,
+ size: fontSize,
+ color: rgb(textColor.r, textColor.g, textColor.b),
+ });
+
+ const wWidth = w.font.widthOfTextAtSize(w.text, fontSize);
+ currentX += wWidth;
+
+ if (i < currentLineWords.length - 1) {
+ const sWidth = w.font.widthOfTextAtSize(' ', fontSize);
+ currentX += sWidth;
+ }
+ } catch (e) {
+ console.warn(`Failed to draw word: "${w.text}"`, e);
+ }
+ }
+
y -= lineHeight;
}
}
@@ -134,6 +201,99 @@ export async function setupTxtToPdfTool() {
if (!uploadBtn || !textBtn || !uploadPanel || !textPanel) return;
+ const langContainer = document.getElementById('language-list-container');
+ const dropdownBtn = document.getElementById('lang-dropdown-btn');
+ const dropdownContent = document.getElementById('lang-dropdown-content');
+ const dropdownText = document.getElementById('lang-dropdown-text');
+ const searchInput = document.getElementById('lang-search');
+
+ if (langContainer && langContainer.children.length === 0) {
+ const allLanguages = Object.keys(languageToFontFamily).sort().map(code => {
+ let name = code;
+ try {
+ const displayNames = new Intl.DisplayNames(['en'], { type: 'language' });
+ name = displayNames.of(code) || code;
+ } catch (e) {
+ console.warn(`Failed to get language name for ${code}`, e);
+ }
+ return { code, name: `${name} (${code})` };
+ });
+
+ const renderLanguages = (filter = '') => {
+ langContainer.innerHTML = '';
+ const lowerFilter = filter.toLowerCase();
+
+ allLanguages.forEach(lang => {
+ if (lang.name.toLowerCase().includes(lowerFilter) || lang.code.toLowerCase().includes(lowerFilter)) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'flex items-center hover:bg-gray-700 p-1 rounded';
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.value = lang.code;
+ checkbox.id = `lang-${lang.code}`;
+ checkbox.className = 'w-4 h-4 text-indigo-600 bg-gray-600 border-gray-500 rounded focus:ring-indigo-500 ring-offset-gray-800';
+ if (lang.code === 'eng') checkbox.checked = true;
+
+ const label = document.createElement('label');
+ label.htmlFor = `lang-${lang.code}`;
+ label.className = 'ml-2 text-sm font-medium text-gray-300 w-full cursor-pointer';
+ label.textContent = lang.name;
+
+ checkbox.addEventListener('change', updateButtonText);
+
+ wrapper.appendChild(checkbox);
+ wrapper.appendChild(label);
+ langContainer.appendChild(wrapper);
+ }
+ });
+ };
+
+ renderLanguages();
+
+ if (searchInput) {
+ searchInput.addEventListener('input', (e) => {
+ const filter = (e.target as HTMLInputElement).value.toLowerCase();
+ const items = langContainer.children;
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i] as HTMLElement;
+ const text = item.textContent?.toLowerCase() || '';
+ if (text.includes(filter)) {
+ item.classList.remove('hidden');
+ } else {
+ item.classList.add('hidden');
+ }
+ }
+ });
+ }
+
+ if (dropdownBtn && dropdownContent) {
+ dropdownBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ dropdownContent.classList.toggle('hidden');
+ });
+
+ document.addEventListener('click', (e) => {
+ if (!dropdownBtn.contains(e.target as Node) && !dropdownContent.contains(e.target as Node)) {
+ dropdownContent.classList.add('hidden');
+ }
+ });
+ }
+
+ function updateButtonText() {
+ const checkboxes = langContainer?.querySelectorAll('input[type="checkbox"]:checked');
+ const count = checkboxes?.length || 0;
+ if (count === 0) {
+ if (dropdownText) dropdownText.textContent = 'Select Languages';
+ } else if (count === 1) {
+ const text = checkboxes[0].nextElementSibling.textContent;
+ if (dropdownText) dropdownText.textContent = text || '1 Language Selected';
+ } else {
+ if (dropdownText) dropdownText.textContent = `${count} Languages Selected`;
+ }
+ }
+ }
+
const switchToUpload = () => {
uploadPanel.classList.remove('hidden');
textPanel.classList.add('hidden');
@@ -155,6 +315,19 @@ export async function setupTxtToPdfTool() {
uploadBtn.addEventListener('click', switchToUpload);
textBtn.addEventListener('click', switchToText);
+ const pageSizeSelect = document.getElementById('page-size') as HTMLSelectElement;
+ const customSizeContainer = document.getElementById('custom-size-container');
+
+ if (pageSizeSelect && customSizeContainer) {
+ pageSizeSelect.addEventListener('change', () => {
+ if (pageSizeSelect.value === 'Custom') {
+ customSizeContainer.classList.remove('hidden');
+ } else {
+ customSizeContainer.classList.add('hidden');
+ }
+ });
+ }
+
const processBtn = document.getElementById('process-btn');
if (processBtn) {
processBtn.onclick = txtToPdf;
@@ -167,25 +340,41 @@ export async function txtToPdf() {
showLoader('Creating PDF...');
try {
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- const fontFamilyKey = document.getElementById('font-family').value;
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- const fontSize = parseInt(document.getElementById('font-size').value) || 12;
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- const pageSizeKey = document.getElementById('page-size').value;
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- const colorHex = document.getElementById('text-color').value;
+ const selectedLanguages: string[] = [];
+ const langContainer = document.getElementById('language-list-container');
+ if (langContainer) {
+ const checkboxes = langContainer.querySelectorAll('input[type="checkbox"]:checked');
+ checkboxes.forEach((cb) => {
+ selectedLanguages.push((cb as HTMLInputElement).value);
+ });
+ }
+ if (selectedLanguages.length === 0) selectedLanguages.push('eng'); // Fallback
+
+ const fontSize = parseInt((document.getElementById('font-size') as HTMLInputElement)?.value) || 12;
+ const pageSizeKey = (document.getElementById('page-size') as HTMLSelectElement)?.value;
+ const orientation = (document.getElementById('page-orientation') as HTMLSelectElement)?.value || 'portrait';
+ const colorHex = (document.getElementById('text-color') as HTMLInputElement)?.value;
+
+ let customWidth: number | undefined;
+ let customHeight: number | undefined;
+ if (pageSizeKey === 'Custom') {
+ customWidth = parseInt((document.getElementById('custom-width') as HTMLInputElement)?.value) || 595;
+ customHeight = parseInt((document.getElementById('custom-height') as HTMLInputElement)?.value) || 842;
+ }
if (isUploadMode && state.files.length > 0) {
if (state.files.length === 1) {
const file = state.files[0];
- const text = await file.text();
+ const text = (await file.text()).normalize('NFC');
const pdfBytes = await createPdfFromText(
text,
- fontFamilyKey,
+ selectedLanguages,
fontSize,
pageSizeKey,
- colorHex
+ colorHex,
+ orientation,
+ customWidth,
+ customHeight
);
const baseName = file.name.replace(/\.txt$/i, '');
downloadFile(
@@ -197,13 +386,16 @@ export async function txtToPdf() {
const zip = new JSZip();
for (const file of state.files) {
- const text = await file.text();
+ const text = (await file.text()).normalize('NFC');
const pdfBytes = await createPdfFromText(
text,
- fontFamilyKey,
+ selectedLanguages,
fontSize,
pageSizeKey,
- colorHex
+ colorHex,
+ orientation,
+ customWidth,
+ customHeight
);
const baseName = file.name.replace(/\.txt$/i, '');
zip.file(`${baseName}.pdf`, pdfBytes);
@@ -213,8 +405,7 @@ export async function txtToPdf() {
downloadFile(zipBlob, 'text-to-pdf.zip');
}
} else {
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- const text = document.getElementById('text-input').value;
+ const text = ((document.getElementById('text-input') as HTMLTextAreaElement)?.value || '').normalize('NFC');
if (!text.trim()) {
showAlert('Input Required', 'Please enter some text to convert.');
hideLoader();
@@ -223,10 +414,13 @@ export async function txtToPdf() {
const pdfBytes = await createPdfFromText(
text,
- fontFamilyKey,
+ selectedLanguages,
fontSize,
pageSizeKey,
- colorHex
+ colorHex,
+ orientation,
+ customWidth,
+ customHeight
);
downloadFile(
new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }),
diff --git a/src/js/main.ts b/src/js/main.ts
index 97a0faa..fe54d03 100644
--- a/src/js/main.ts
+++ b/src/js/main.ts
@@ -140,15 +140,20 @@ const init = () => {
hideBrandingSections();
}
- // Hide shortcuts button on touch devices
- const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
- if (isTouchDevice) {
- const shortcutsBtn = document.getElementById('open-shortcuts-btn');
- if (shortcutsBtn) {
- shortcutsBtn.style.display = 'none';
- }
- }
+ // Hide shortcuts buttons on mobile devices (Android/iOS)
+ // exclude iPad -> users can connect keyboard and use shortcuts
+ const isMobile = /Android|iPhone|iPod/i.test(navigator.userAgent);
+ const keyboardShortcutBtn = document.getElementById('shortcut');
+ const shortcutSettingsBtn = document.getElementById('open-shortcuts-btn');
+ if (isMobile) {
+ keyboardShortcutBtn.style.display = 'none';
+ shortcutSettingsBtn.style.display = 'none';
+ } else {
+ keyboardShortcutBtn.textContent = navigator.userAgent.toUpperCase().includes('MAC')
+ ? '⌘ + K'
+ : 'Ctrl + K';
+ }
dom.toolGrid.textContent = '';
@@ -205,6 +210,22 @@ const init = () => {
const searchBar = document.getElementById('search-bar');
const categoryGroups = dom.toolGrid.querySelectorAll('.category-group');
+
+ const fuzzyMatch = (searchTerm: string, targetText: string): boolean => {
+ if (!searchTerm) return true;
+
+ let searchIndex = 0;
+ let targetIndex = 0;
+
+ while (searchIndex < searchTerm.length && targetIndex < targetText.length) {
+ if (searchTerm[searchIndex] === targetText[targetIndex]) {
+ searchIndex++;
+ }
+ targetIndex++;
+ }
+
+ return searchIndex === searchTerm.length;
+ };
searchBar.addEventListener('input', () => {
// @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
@@ -218,8 +239,9 @@ const init = () => {
const toolName = card.querySelector('h3').textContent.toLowerCase();
const toolSubtitle =
card.querySelector('p')?.textContent.toLowerCase() || '';
+
const isMatch =
- toolName.includes(searchTerm) || toolSubtitle.includes(searchTerm);
+ fuzzyMatch(searchTerm, toolName) || fuzzyMatch(searchTerm, toolSubtitle);
card.classList.toggle('hidden', !isMatch);
if (isMatch) {
@@ -243,17 +265,6 @@ const init = () => {
}
});
- const shortcutK = document.getElementById('shortcut');
- const isIosOrAndroid = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
-
- if (isIosOrAndroid) {
- shortcutK.style.display = 'none';
- } else {
- shortcutK.textContent = navigator.userAgent.toUpperCase().includes('MAC')
- ? '⌘ + K'
- : 'Ctrl + K';
- }
-
dom.toolGrid.addEventListener('click', (e) => {
// @ts-expect-error TS(2339) FIXME: Property 'closest' does not exist on type 'EventTa... Remove this comment to see the full error message
const card = e.target.closest('.tool-card');
diff --git a/src/js/types/form-creator.ts b/src/js/types/form-creator.ts
new file mode 100644
index 0000000..f0f7c5b
--- /dev/null
+++ b/src/js/types/form-creator.ts
@@ -0,0 +1,40 @@
+export interface FormField {
+ id: string
+ type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'optionlist' | 'button' | 'signature' | 'date' | 'image'
+ x: number
+ y: number
+ width: number
+ height: number
+ name: string
+ defaultValue: string
+ fontSize: number
+ alignment: 'left' | 'center' | 'right'
+ textColor: string
+ required: boolean
+ readOnly: boolean
+ tooltip: string
+ combCells: number
+ maxLength: number
+ options?: string[]
+ checked?: boolean
+ exportValue?: string
+ groupName?: string
+ label?: string
+ pageIndex: number
+ action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide'
+ actionUrl?: string
+ jsScript?: string
+ targetFieldName?: string
+ visibilityAction?: 'show' | 'hide' | 'toggle'
+ dateFormat?: string
+ multiline?: boolean
+ borderColor?: string
+ hideBorder?: boolean
+}
+
+export interface PageData {
+ index: number
+ width: number
+ height: number
+ pdfPageData?: string
+}
diff --git a/src/js/types/index.ts b/src/js/types/index.ts
new file mode 100644
index 0000000..1e88b7b
--- /dev/null
+++ b/src/js/types/index.ts
@@ -0,0 +1,2 @@
+export * from './ocr.js';
+export * from './form-creator.js';
diff --git a/src/js/types/ocr.ts b/src/js/types/ocr.ts
new file mode 100644
index 0000000..84e7d53
--- /dev/null
+++ b/src/js/types/ocr.ts
@@ -0,0 +1,10 @@
+export interface Word {
+ text: string;
+ bbox: {
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+ };
+ confidence: number;
+}
diff --git a/src/js/ui.ts b/src/js/ui.ts
index e85d265..f929b49 100644
--- a/src/js/ui.ts
+++ b/src/js/ui.ts
@@ -1309,12 +1309,21 @@ export const toolTemplates = {
-
Font Family
-
- Helvetica
- Times New Roman
- Courier
-
+
Select Languages
+
+
+ English (Default)
+
+
+
+
Font Size
@@ -1323,10 +1332,42 @@ export const toolTemplates = {
Page Size
- A4
- Letter
+
+ A4 (210 x 297 mm)
+ A3 (297 x 420 mm)
+ A5 (148 x 210 mm)
+ A6 (105 x 148 mm)
+
+
+ Letter (8.5 x 11 in)
+ Legal (8.5 x 14 in)
+ Tabloid (11 x 17 in)
+ Executive (7.25 x 10.5 in)
+
+
+ B4 (250 x 353 mm)
+ B5 (176 x 250 mm)
+
+ Custom Size
+
+ Orientation
+
+ Portrait
+ Landscape
+
+
+
Text Color
diff --git a/src/js/utils/font-loader.ts b/src/js/utils/font-loader.ts
new file mode 100644
index 0000000..7d2bc83
--- /dev/null
+++ b/src/js/utils/font-loader.ts
@@ -0,0 +1,281 @@
+import { languageToFontFamily, fontFamilyToUrl } from '../config/font-mappings.js';
+
+const fontCache: Map
= new Map();
+
+const DB_NAME = 'bentopdf-fonts';
+const DB_VERSION = 1;
+const STORE_NAME = 'fonts';
+
+async function openFontDB(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result);
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.createObjectStore(STORE_NAME);
+ }
+ };
+ });
+}
+
+async function getCachedFontFromDB(fontFamily: string): Promise {
+ try {
+ const db = await openFontDB();
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE_NAME, 'readonly');
+ const store = transaction.objectStore(STORE_NAME);
+ const request = store.get(fontFamily);
+
+ request.onsuccess = () => resolve(request.result || null);
+ request.onerror = () => reject(request.error);
+ });
+ } catch (error) {
+ console.warn('IndexedDB read failed:', error);
+ return null;
+ }
+}
+
+async function saveFontToDB(fontFamily: string, fontBuffer: ArrayBuffer): Promise {
+ try {
+ const db = await openFontDB();
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE_NAME, 'readwrite');
+ const store = transaction.objectStore(STORE_NAME);
+ const request = store.put(fontBuffer, fontFamily);
+
+ request.onsuccess = () => resolve();
+ request.onerror = () => reject(request.error);
+ });
+ } catch (error) {
+ console.warn('IndexedDB write failed:', error);
+ }
+}
+
+export async function getFontForLanguage(lang: string): Promise {
+ const fontFamily = languageToFontFamily[lang] || 'Noto Sans';
+
+ if (fontCache.has(fontFamily)) {
+ return fontCache.get(fontFamily)!;
+ }
+ const cachedFont = await getCachedFontFromDB(fontFamily);
+ if (cachedFont) {
+ fontCache.set(fontFamily, cachedFont);
+ return cachedFont;
+ }
+
+ try {
+ const fontUrl = fontFamilyToUrl[fontFamily] || fontFamilyToUrl['Noto Sans'];
+
+ const fontResponse = await fetch(fontUrl);
+
+ if (!fontResponse.ok) {
+ throw new Error(`Failed to fetch font file: ${fontResponse.statusText}`);
+ }
+
+ const fontBuffer = await fontResponse.arrayBuffer();
+
+ fontCache.set(fontFamily, fontBuffer);
+ await saveFontToDB(fontFamily, fontBuffer);
+
+ return fontBuffer;
+ } catch (error) {
+ console.warn(`Failed to fetch font for ${lang} (${fontFamily}), falling back to default.`, error);
+
+ if (fontFamily !== 'Noto Sans') {
+ return await getFontForLanguage('eng');
+ }
+
+ throw error;
+ }
+}
+
+export function detectScripts(text: string): string[] {
+ const scripts = new Set();
+
+ // Japanese: Hiragana (\u3040-\u309F) & Katakana (\u30A0-\u30FF)
+ if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) {
+ scripts.add('jpn');
+ }
+
+ // Korean: Hangul Syllables (\uAC00-\uD7A3) & Jamo (\u1100-\u11FF)
+ if (/[\uAC00-\uD7A3\u1100-\u11FF]/.test(text)) {
+ scripts.add('kor');
+ }
+
+ // Chinese: CJK Unified Ideographs (\u4E00-\u9FFF) & Ext A (\u3400-\u4DBF)
+ if (/[\u4E00-\u9FFF\u3400-\u4DBF]/.test(text)) {
+ scripts.add('chi_sim');
+ }
+
+ // Check for Arabic
+ if (/[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]/.test(text)) {
+ scripts.add('ara');
+ }
+
+ // Check for Devanagari (Hindi, Marathi, etc.)
+ if (/[\u0900-\u097F]/.test(text)) scripts.add('hin');
+
+ // Check for Bengali
+ if (/[\u0980-\u09FF]/.test(text)) scripts.add('ben');
+
+ // Check for Tamil
+ if (/[\u0B80-\u0BFF]/.test(text)) scripts.add('tam');
+
+ // Check for Telugu
+ if (/[\u0C00-\u0C7F]/.test(text)) scripts.add('tel');
+
+ // Check for Kannada
+ if (/[\u0C80-\u0CFF]/.test(text)) scripts.add('kan');
+
+ // Check for Malayalam
+ if (/[\u0D00-\u0D7F]/.test(text)) scripts.add('mal');
+
+ // Check for Gujarati
+ if (/[\u0A80-\u0AFF]/.test(text)) scripts.add('guj');
+
+ // Check for Punjabi (Gurmukhi)
+ if (/[\u0A00-\u0A7F]/.test(text)) scripts.add('pan');
+
+ // Check for Oriya
+ if (/[\u0B00-\u0B7F]/.test(text)) scripts.add('ori');
+
+ // Check for Sinhala
+ if (/[\u0D80-\u0DFF]/.test(text)) scripts.add('sin');
+
+ // Check for Thai
+ if (/[\u0E00-\u0E7F]/.test(text)) scripts.add('tha');
+
+ // Check for Lao
+ if (/[\u0E80-\u0EFF]/.test(text)) scripts.add('lao');
+
+ // Check for Khmer
+ if (/[\u1780-\u17FF]/.test(text)) scripts.add('khm');
+
+ // Check for Myanmar
+ if (/[\u1000-\u109F]/.test(text)) scripts.add('mya');
+
+ // Check for Tibetan
+ if (/[\u0F00-\u0FFF]/.test(text)) scripts.add('bod');
+
+ // Check for Georgian
+ if (/[\u10A0-\u10FF]/.test(text)) scripts.add('kat');
+
+ // Check for Armenian
+ if (/[\u0530-\u058F]/.test(text)) scripts.add('hye');
+
+ // Check for Hebrew
+ if (/[\u0590-\u05FF]/.test(text)) scripts.add('heb');
+
+ // Check for Ethiopic
+ if (/[\u1200-\u137F]/.test(text)) scripts.add('amh');
+
+ // Check for Cherokee
+ if (/[\u13A0-\u13FF]/.test(text)) scripts.add('chr');
+
+ // Check for Syriac
+ if (/[\u0700-\u074F]/.test(text)) scripts.add('syr');
+
+ if (scripts.size === 0 || /[a-zA-Z]/.test(text)) {
+ scripts.add('eng');
+ }
+
+ return Array.from(scripts);
+}
+
+export function getLanguageForChar(char: string): string {
+ const code = char.charCodeAt(0);
+
+ // Latin (Basic + Supplement + Extended)
+ if (code <= 0x024F) return 'eng';
+
+ // Japanese: Hiragana & Katakana
+ if (
+ (code >= 0x3040 && code <= 0x309F) || // Hiragana
+ (code >= 0x30A0 && code <= 0x30FF) // Katakana
+ ) return 'jpn';
+
+ // Korean: Hangul Syllables & Jamo
+ if (
+ (code >= 0xAC00 && code <= 0xD7A3) || // Hangul Syllables
+ (code >= 0x1100 && code <= 0x11FF) // Hangul Jamo
+ ) return 'kor';
+
+ // Chinese: CJK Unified Ideographs (Han)
+ if (
+ (code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified
+ (code >= 0x3400 && code <= 0x4DBF) // CJK Ext A
+ ) return 'chi_sim';
+
+ // Arabic
+ if ((code >= 0x0600 && code <= 0x06FF) || (code >= 0x0750 && code <= 0x077F) || (code >= 0x08A0 && code <= 0x08FF)) return 'ara';
+
+ // Devanagari
+ if (code >= 0x0900 && code <= 0x097F) return 'hin';
+
+ // Bengali
+ if (code >= 0x0980 && code <= 0x09FF) return 'ben';
+
+ // Tamil
+ if (code >= 0x0B80 && code <= 0x0BFF) return 'tam';
+
+ // Telugu
+ if (code >= 0x0C00 && code <= 0x0C7F) return 'tel';
+
+ // Kannada
+ if (code >= 0x0C80 && code <= 0x0CFF) return 'kan';
+
+ // Malayalam
+ if (code >= 0x0D00 && code <= 0x0D7F) return 'mal';
+
+ // Gujarati
+ if (code >= 0x0A80 && code <= 0x0AFF) return 'guj';
+
+ // Punjabi (Gurmukhi)
+ if (code >= 0x0A00 && code <= 0x0A7F) return 'pan';
+
+ // Oriya
+ if (code >= 0x0B00 && code <= 0x0B7F) return 'ori';
+
+ // Sinhala
+ if (code >= 0x0D80 && code <= 0x0DFF) return 'sin';
+
+ // Thai
+ if (code >= 0x0E00 && code <= 0x0E7F) return 'tha';
+
+ // Lao
+ if (code >= 0x0E80 && code <= 0x0EFF) return 'lao';
+
+ // Khmer
+ if (code >= 0x1780 && code <= 0x17FF) return 'khm';
+
+ // Myanmar
+ if (code >= 0x1000 && code <= 0x109F) return 'mya';
+
+ // Tibetan
+ if (code >= 0x0F00 && code <= 0x0FFF) return 'bod';
+
+ // Georgian
+ if (code >= 0x10A0 && code <= 0x10FF) return 'kat';
+
+ // Armenian
+ if (code >= 0x0530 && code <= 0x058F) return 'hye';
+
+ // Hebrew
+ if (code >= 0x0590 && code <= 0x05FF) return 'heb';
+
+ // Ethiopic
+ if (code >= 0x1200 && code <= 0x137F) return 'amh';
+
+ // Cherokee
+ if (code >= 0x13A0 && code <= 0x13FF) return 'chr';
+
+ // Syriac
+ if (code >= 0x0700 && code <= 0x074F) return 'syr';
+
+ // Default to English (Latin)
+ return 'eng';
+}
diff --git a/src/pages/form-creator.html b/src/pages/form-creator.html
index 3206a6f..e0deb9b 100644
--- a/src/pages/form-creator.html
+++ b/src/pages/form-creator.html
@@ -205,6 +205,27 @@
disabled>
+
+ Go to:
+
+ Go
+
+
+
+ Grid:
+
+ x
+
+
+
+
+
Date: Wed, 3 Dec 2025 23:15:15 +0530
Subject: [PATCH 02/21] Release v1.10.1
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 89bbb90..6b437c5 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "bento-pdf",
"private": true,
- "version": "1.10.0",
+ "version": "1.10.1",
"license": "Apache-2.0",
"type": "module",
"scripts": {
From fb3063324c9283af3c375c9652aba46bfc5aa30f Mon Sep 17 00:00:00 2001
From: abdullahalam123
Date: Thu, 4 Dec 2025 12:26:57 +0530
Subject: [PATCH 03/21] feat: implement dedicated merge PDF tool page with new
logic and UI structure
---
src/js/config/tools.ts | 4 +-
src/js/logic/index.ts | 4 +-
src/js/logic/merge-pdf-page.ts | 578 +++++++++++++++++++++++++++++++++
src/js/logic/merge.ts | 470 ---------------------------
src/js/ui.ts | 36 --
src/pages/merge-pdf.html | 260 +++++++++++++++
vite.config.ts | 1 +
7 files changed, 843 insertions(+), 510 deletions(-)
create mode 100644 src/js/logic/merge-pdf-page.ts
delete mode 100644 src/js/logic/merge.ts
create mode 100644 src/pages/merge-pdf.html
diff --git a/src/js/config/tools.ts b/src/js/config/tools.ts
index 35aef9a..700d25d 100644
--- a/src/js/config/tools.ts
+++ b/src/js/config/tools.ts
@@ -10,7 +10,7 @@ export const categories = [
subtitle: 'Merge, Split, Organize, Delete, Rotate, Add Blank Pages, Extract and Duplicate in an unified interface.',
},
{
- id: 'merge',
+ href: '/src/pages/merge-pdf.html',
name: 'Merge PDF',
icon: 'combine',
subtitle: 'Combine multiple PDFs into one file. Preserves Bookmarks.',
@@ -301,7 +301,7 @@ export const categories = [
subtitle: 'Make a PDF searchable and copyable.',
},
{
- id: 'merge',
+ href: '/src/pages/merge-pdf.html',
name: 'Merge PDF',
icon: 'combine',
subtitle: 'Combine multiple PDFs into one file.',
diff --git a/src/js/logic/index.ts b/src/js/logic/index.ts
index ecf1eff..6434a18 100644
--- a/src/js/logic/index.ts
+++ b/src/js/logic/index.ts
@@ -1,4 +1,4 @@
-import { merge, setupMergeTool } from './merge.js';
+
import { setupSplitTool, split } from './split.js';
import { encrypt } from './encrypt.js';
import { decrypt } from './decrypt.js';
@@ -70,7 +70,7 @@ import { removeRestrictions } from './remove-restrictions.js';
import { repairPdf } from './repair-pdf.js';
export const toolLogic = {
- merge: { process: merge, setup: setupMergeTool },
+
split: { process: split, setup: setupSplitTool },
encrypt,
decrypt,
diff --git a/src/js/logic/merge-pdf-page.ts b/src/js/logic/merge-pdf-page.ts
new file mode 100644
index 0000000..3b99ab4
--- /dev/null
+++ b/src/js/logic/merge-pdf-page.ts
@@ -0,0 +1,578 @@
+import { showLoader, hideLoader, showAlert } from '../ui.js';
+import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.js';
+import { state } from '../state.js';
+import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.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();
+
+interface MergeState {
+ 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,
+};
+
+const mergeWorker = new Worker('/workers/merge.worker.js');
+
+function initializeFileListSortable() {
+ const fileList = document.getElementById('file-list');
+ if (!fileList) return;
+
+ 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';
+ },
+ });
+}
+
+function initializePageThumbnailsSortable() {
+ const container = document.getElementById('page-merge-preview');
+ if (!container) return;
+
+ 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';
+ },
+ });
+}
+
+function generateFileHash() {
+ 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 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 (mergeState.isRendering) {
+ return;
+ }
+
+ mergeState.isRendering = true;
+ container.textContent = '';
+
+ cleanupLazyRendering();
+
+ 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 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 });
+ }
+ }
+ );
+ }
+
+ 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;
+ }
+}
+
+export async function merge() {
+ showLoader('Merging PDFs...');
+ try {
+ // @ts-ignore
+ const jobs: MergeJob[] = [];
+ // @ts-ignore
+ const filesToMerge: MergeFile[] = [];
+ const uniqueFileNames = new Set();
+
+ if (mergeState.activeMode === 'file') {
+ const fileList = document.getElementById('file-list');
+ if (!fileList) throw new Error('File list not found');
+
+ const sortedFiles = Array.from(fileList.children)
+ .map((li) => {
+ return state.files.find((f) => f.name === (li as HTMLElement).dataset.fileName);
+ })
+ .filter(Boolean);
+
+ 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;
+
+ uniqueFileNames.add(file.name);
+
+ if (rangeInput && rangeInput.value.trim()) {
+ jobs.push({
+ fileName: file.name,
+ rangeType: 'specific',
+ rangeString: rangeInput.value.trim()
+ });
+ } else {
+ 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
+
+ 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;
+
+ 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
+ });
+ }
+ }
+ }
+
+ 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
+ };
+
+ 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!');
+ } 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;
+
+ const wasInPageMode = mergeState.activeMode === 'page';
+
+ 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;
+
+ 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');
+
+ if (!fileModeBtn || !pageModeBtn || !filePanel || !pagePanel || !fileList) return;
+
+ fileList.textContent = ''; // Clear list safely
+ (state.files as File[]).forEach((f) => {
+ 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 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 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);
+
+ const rangeDiv = document.createElement('div');
+ rangeDiv.className = 'mt-2';
+
+ 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';
+
+ rangeDiv.append(label, input);
+ li.append(mainDiv, rangeDiv);
+ fileList.appendChild(li);
+ });
+
+ initializeFileListSortable();
+
+ 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;
+
+ 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');
+ });
+
+ newPageModeBtn.addEventListener('click', async () => {
+ if (mergeState.activeMode === 'page') return;
+
+ 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');
+
+ await renderPageMergeThumbnails();
+ });
+
+ 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');
+
+ 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 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 = '/';
+ });
+ }
+
+ const updateUI = async () => {
+ 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 (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();
+ }
+ fileInput.value = '';
+ });
+
+ 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', 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('click', () => {
+ fileInput.click();
+ });
+ }
+
+ if (addMoreBtn) {
+ addMoreBtn.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
+
+ if (clearFilesBtn) {
+ clearFilesBtn.addEventListener('click', async () => {
+ state.files = [];
+ await updateUI();
+ });
+ }
+
+ if (processBtn) {
+ processBtn.addEventListener('click', async () => {
+ await merge();
+ });
+ }
+
+ const alertOkBtn = document.getElementById('alert-ok-btn');
+ if (alertOkBtn) {
+ alertOkBtn.addEventListener('click', async () => {
+ if (mergeState.mergeSuccess) {
+ state.files = [];
+ mergeState.mergeSuccess = false;
+ await updateUI();
+ }
+ });
+ }
+});
diff --git a/src/js/logic/merge.ts b/src/js/logic/merge.ts
deleted file mode 100644
index 3024876..0000000
--- a/src/js/logic/merge.ts
+++ /dev/null
@@ -1,470 +0,0 @@
-import { showLoader, hideLoader, showAlert } from '../ui.ts';
-import { downloadFile, readFileAsArrayBuffer, getPDFDocument } from '../utils/helpers.ts';
-import { state } from '../state.ts';
-import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.ts';
-
-import { createIcons, icons } from 'lucide';
-import * as pdfjsLib from 'pdfjs-dist';
-import Sortable from 'sortablejs';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
-
-interface MergeState {
- pdfDocs: Record;
- pdfBytes: Record;
- activeMode: 'file' | 'page';
- sortableInstances: {
- fileList?: Sortable;
- pageThumbnails?: Sortable;
- };
- isRendering: boolean;
- cachedThumbnails: boolean | null;
- lastFileHash: string | null;
-}
-
-const mergeState: MergeState = {
- pdfDocs: {},
- pdfBytes: {},
- activeMode: 'file',
- sortableInstances: {},
- isRendering: false,
- cachedThumbnails: null,
- lastFileHash: null,
-};
-
-const mergeWorker = new Worker('/workers/merge.worker.js');
-
-function initializeFileListSortable() {
- const fileList = document.getElementById('file-list');
- if (!fileList) return;
-
- 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';
- },
- });
-}
-
-function initializePageThumbnailsSortable() {
- const container = document.getElementById('page-merge-preview');
- if (!container) return;
-
- 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';
- },
- });
-}
-
-function generateFileHash() {
- 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 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 (mergeState.isRendering) {
- return;
- }
-
- mergeState.isRendering = true;
- container.textContent = '';
-
- cleanupLazyRendering();
-
- 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 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 });
- }
- }
- );
- }
-
- 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;
- }
-}
-
-export async function merge() {
- showLoader('Merging PDFs...');
- try {
- const jobs: MergeJob[] = [];
- const filesToMerge: MergeFile[] = [];
- const uniqueFileNames = new Set();
-
- if (mergeState.activeMode === 'file') {
- const fileList = document.getElementById('file-list');
- if (!fileList) throw new Error('File list not found');
-
- const sortedFiles = Array.from(fileList.children)
- .map((li) => {
- return state.files.find((f) => f.name === (li as HTMLElement).dataset.fileName);
- })
- .filter(Boolean);
-
- 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;
-
- uniqueFileNames.add(file.name);
-
- if (rangeInput && rangeInput.value.trim()) {
- jobs.push({
- fileName: file.name,
- rangeType: 'specific',
- rangeString: rangeInput.value.trim()
- });
- } else {
- 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
-
- 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;
-
- 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
- });
- }
- }
- }
-
- 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 });
- }
- }
-
- const message: MergeMessage = {
- command: 'merge',
- files: filesToMerge,
- jobs: jobs
- };
-
- mergeWorker.postMessage(message, filesToMerge.map(f => f.data));
-
- mergeWorker.onmessage = (e: MessageEvent) => {
- hideLoader();
- if (e.data.status === 'success') {
- const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });
- downloadFile(blob, 'merged.pdf');
- showAlert('Success', 'PDFs merged successfully!');
- } 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 setupMergeTool() {
- 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';
-
- 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;
-
- 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');
-
- if (!fileModeBtn || !pageModeBtn || !filePanel || !pagePanel || !fileList) return;
-
- fileList.textContent = ''; // Clear list safely
- (state.files as File[]).forEach((f) => {
- 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 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 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);
-
- const rangeDiv = document.createElement('div');
- rangeDiv.className = 'mt-2';
-
- 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';
-
- rangeDiv.append(label, input);
- li.append(mainDiv, rangeDiv);
- fileList.appendChild(li);
- });
-
- initializeFileListSortable();
-
- 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;
-
- 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');
- });
-
- newPageModeBtn.addEventListener('click', async () => {
- if (mergeState.activeMode === 'page') return;
-
- 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');
-
- await renderPageMergeThumbnails();
- });
-
- 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');
-
- await renderPageMergeThumbnails();
- } else {
- newFileModeBtn.classList.add('bg-indigo-600', 'text-white');
- newPageModeBtn.classList.add('bg-gray-700', 'text-gray-300');
- }
-}
diff --git a/src/js/ui.ts b/src/js/ui.ts
index f929b49..140b7eb 100644
--- a/src/js/ui.ts
+++ b/src/js/ui.ts
@@ -432,43 +432,7 @@ const createFileInputHTML = (options = {}) => {
};
export const toolTemplates = {
- merge: () => `
- Merge PDFs
- Combine whole files, or select specific pages to merge into a new document.
- ${createFileInputHTML({ multiple: true, showControls: true })}
-
-
- File Mode
- Page Mode
-
-
-
-
-
How it works:
-
- Click and drag the icon to change the order of the files.
- In the "Pages" box for each file, you can specify ranges (e.g., "1-3, 5") to merge only those pages.
- Leave the "Pages" box blank to include all pages from that file.
-
-
-
-
-
-
-
-
How it works:
-
- All pages from your uploaded PDFs are shown below.
- Simply drag and drop the individual page thumbnails to create the exact order you want for your new file.
-
-
-
-
-
-
Merge PDFs
-
-`,
split: () => `
Split PDF
diff --git a/src/pages/merge-pdf.html b/src/pages/merge-pdf.html
new file mode 100644
index 0000000..f5f4ec7
--- /dev/null
+++ b/src/pages/merge-pdf.html
@@ -0,0 +1,260 @@
+
+
+
+
+
+
+ Merge PDF - BentoPDF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back to Tools
+
+
+
Merge PDFs
+
+ Combine whole files, or select specific pages to merge into a new document.
+
+
+
+
+
+
+
Click to select a file or drag and
+ drop
+
PDFs or Images
+
Your files never leave your device.
+
+
+
+
+
+
+
+ Add More Files
+
+
+ Clear All
+
+
+
+
+
+ File
+ Mode
+ Page Mode
+
+
+
+
+
How it works:
+
+ Click and drag the icon to change the
+ order of the files.
+ In the "Pages" box for each file, you can specify ranges (e.g., "1-3, 5") to merge only those pages.
+
+ Leave the "Pages" box blank to include all pages from that file.
+
+
+
+
+
+
+
+
How it works:
+
+ All pages from your uploaded PDFs are shown below.
+ Simply drag and drop the individual page thumbnails to create the exact order you want for your new
+ file.
+
+
+
+
+
+
+
Merge PDFs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
BentoPDF
+
+
+ © 2025 BentoPDF. All rights reserved.
+
+
+ Version
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
index 159e558..d1bef63 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -62,6 +62,7 @@ export default defineConfig(({ mode }) => ({
'add-stamps': resolve(__dirname, 'src/pages/add-stamps.html'),
'form-creator': resolve(__dirname, 'src/pages/form-creator.html'),
'repair-pdf': resolve(__dirname, 'src/pages/repair-pdf.html'),
+ 'merge-pdf': resolve(__dirname, 'src/pages/merge-pdf.html'),
},
},
},
From 6676fe9f8915fe08029efa5513d9b1518fbc0bbb Mon Sep 17 00:00:00 2001
From: abdullahalam123
Date: Thu, 4 Dec 2025 14:02:20 +0530
Subject: [PATCH 04/21] feat: Reimplement PDF splitting functionality on a new
dedicated page.
---
src/js/config/tools.ts | 2 +-
src/js/logic/index.ts | 3 +-
src/js/logic/merge-pdf-page.ts | 82 +-
src/js/logic/split-pdf-page.ts | 547 +++++
src/js/logic/split.ts | 361 ----
src/js/ui.ts | 3473 ++++++++++++++++----------------
src/pages/merge-pdf.html | 2 +-
src/pages/split-pdf.html | 334 +++
vite.config.ts | 1 +
9 files changed, 2634 insertions(+), 2171 deletions(-)
create mode 100644 src/js/logic/split-pdf-page.ts
delete mode 100644 src/js/logic/split.ts
create mode 100644 src/pages/split-pdf.html
diff --git a/src/js/config/tools.ts b/src/js/config/tools.ts
index 700d25d..f73abee 100644
--- a/src/js/config/tools.ts
+++ b/src/js/config/tools.ts
@@ -16,7 +16,7 @@ export const categories = [
subtitle: 'Combine multiple PDFs into one file. Preserves Bookmarks.',
},
{
- id: 'split',
+ href: '/src/pages/split-pdf.html',
name: 'Split PDF',
icon: 'scissors',
subtitle: 'Extract a range of pages into a new PDF.',
diff --git a/src/js/logic/index.ts b/src/js/logic/index.ts
index 6434a18..033d1be 100644
--- a/src/js/logic/index.ts
+++ b/src/js/logic/index.ts
@@ -1,5 +1,5 @@
-import { setupSplitTool, split } from './split.js';
+
import { encrypt } from './encrypt.js';
import { decrypt } from './decrypt.js';
import { organize } from './organize.js';
@@ -71,7 +71,6 @@ import { repairPdf } from './repair-pdf.js';
export const toolLogic = {
- split: { process: split, setup: setupSplitTool },
encrypt,
decrypt,
'remove-restrictions': removeRestrictions,
diff --git a/src/js/logic/merge-pdf-page.ts b/src/js/logic/merge-pdf-page.ts
index 3b99ab4..25712f2 100644
--- a/src/js/logic/merge-pdf-page.ts
+++ b/src/js/logic/merge-pdf-page.ts
@@ -201,6 +201,59 @@ async function renderPageMergeThumbnails() {
}
}
+const updateUI = async () => {
+ 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 = '';
+ }
+};
+
+const resetState = async () => {
+ state.files = [];
+ state.pdfDoc = null;
+
+ 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 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');
+
+ 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');
+ }
+
+ await updateUI();
+};
+
+
export async function merge() {
showLoader('Merging PDFs...');
try {
@@ -320,7 +373,9 @@ export async function merge() {
const blob = new Blob([e.data.pdfBytes], { type: 'application/pdf' });
downloadFile(blob, 'merged.pdf');
mergeState.mergeSuccess = true;
- showAlert('Success', 'PDFs merged successfully!');
+ 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.');
@@ -494,19 +549,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
- const updateUI = async () => {
- 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 (fileInput && dropZone) {
fileInput.addEventListener('change', async (e) => {
@@ -565,14 +608,5 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
- const alertOkBtn = document.getElementById('alert-ok-btn');
- if (alertOkBtn) {
- alertOkBtn.addEventListener('click', async () => {
- if (mergeState.mergeSuccess) {
- state.files = [];
- mergeState.mergeSuccess = false;
- await updateUI();
- }
- });
- }
+
});
diff --git a/src/js/logic/split-pdf-page.ts b/src/js/logic/split-pdf-page.ts
new file mode 100644
index 0000000..5cf7f4e
--- /dev/null
+++ b/src/js/logic/split-pdf-page.ts
@@ -0,0 +1,547 @@
+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 { state } from '../state.js';
+import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.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();
+
+document.addEventListener('DOMContentLoaded', () => {
+ 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');
+
+ // 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 = '/';
+ });
+ }
+
+ 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 nameSizeContainer = document.createElement('div');
+ nameSizeContainer.className = 'flex items-center gap-2';
+
+ 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)})`;
+
+ nameSizeContainer.append(nameSpan, sizeSpan);
+
+ const pagesSpan = document.createElement('span');
+ pagesSpan.className = 'text-xs text-gray-500 mt-0.5';
+ pagesSpan.textContent = 'Loading pages...'; // Placeholder
+
+ infoContainer.append(nameSizeContainer, pagesSpan);
+
+ // 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
+ pagesSpan.textContent = `${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'));
+ }
+
+ // 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(',');
+ for (const range of ranges) {
+ const trimmedRange = range.trim();
+ 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++) indicesToExtract.push(i - 1);
+ } else {
+ const pageNum = Number(trimmedRange);
+ if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue;
+ indicesToExtract.push(pageNum - 1);
+ }
+ }
+ 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 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'
+ );
+ }
+
+ 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);
+ fileInput.value = '';
+ });
+
+ 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);
+ }
+ }
+ });
+
+ dropZone.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
+
+ 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);
+ }
+});
diff --git a/src/js/logic/split.ts b/src/js/logic/split.ts
deleted file mode 100644
index 45796a0..0000000
--- a/src/js/logic/split.ts
+++ /dev/null
@@ -1,361 +0,0 @@
-import { showLoader, hideLoader, showAlert } from '../ui.js';
-import { createIcons, icons } from 'lucide';
-import * as pdfjsLib from 'pdfjs-dist';
-import { downloadFile, getPDFDocument } from '../utils/helpers.js';
-
-pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
-import { state } from '../state.js';
-import { renderPagesProgressively, cleanupLazyRendering } from '../utils/render-utils.js';
-import JSZip from 'jszip';
-
-import { PDFDocument as PDFLibDocument } from 'pdf-lib';
-
-let visualSelectorRendered = false;
-
-async function renderVisualSelector() {
- 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 {
- 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';
- // @ts-expect-error TS(2322) FIXME: Type 'number' is not assignable to type 'string'.
- wrapper.dataset.pageIndex = pageNumber - 1;
-
- 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();
- }
-}
-
-export function setupSplitTool() {
- const splitModeSelect = document.getElementById('split-mode');
- 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 (!splitModeSelect) return;
-
- splitModeSelect.addEventListener('change', (e) => {
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'EventTarg... Remove this comment to see the full error message
- const mode = e.target.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');
- }
- };
-
- const nValueInput = document.getElementById('split-n-value') as HTMLInputElement;
- if (nValueInput) {
- nValueInput.addEventListener('input', updateWarning);
- updateWarning();
- }
- }
- });
-}
-
-export async function split() {
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- const splitMode = document.getElementById('split-mode').value;
- const downloadAsZip =
- (document.getElementById('download-as-zip') as HTMLInputElement)?.checked ||
- false;
-
- showLoader('Splitting PDF...');
-
- try {
- const totalPages = state.pdfDoc.getPageCount();
- let indicesToExtract: any = [];
-
- switch (splitMode) {
- case 'range':
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'HTMLEleme... Remove this comment to see the full error message
- const pageRangeInput = document.getElementById('page-range').value;
- if (!pageRangeInput) throw new Error('Please enter a page range.');
- const ranges = pageRangeInput.split(',');
- for (const range of ranges) {
- const trimmedRange = range.trim();
- 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++) indicesToExtract.push(i - 1);
- } else {
- const pageNum = Number(trimmedRange);
- if (isNaN(pageNum) || pageNum < 1 || pageNum > totalPages) continue;
- indicesToExtract.push(pageNum - 1);
- }
- }
- break;
-
- case 'even-odd':
- const choiceElement = document.querySelector(
- 'input[name="even-odd-choice"]:checked'
- );
- if (!choiceElement) throw new Error('Please select even or odd pages.');
- // @ts-expect-error TS(2339) FIXME: Property 'value' does not exist on type 'Element'.
- 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')
- )
- // @ts-expect-error TS(2339) FIXME: Property 'dataset' does not exist on type 'Element... Remove this comment to see the full error message
- .map((el) => parseInt(el.dataset.pageIndex));
- 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();
- 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();
- 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-expect-error TS(2365) FIXME: Operator '+' cannot be applied to types 'unknown' ... Remove this comment to see the full error message
- 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;
- }
- } catch (e) {
- console.error(e);
- showAlert(
- 'Error',
- e.message || 'Failed to split PDF. Please check your selection.'
- );
- } finally {
- hideLoader();
- }
-}
diff --git a/src/js/ui.ts b/src/js/ui.ts
index 140b7eb..4fd847b 100644
--- a/src/js/ui.ts
+++ b/src/js/ui.ts
@@ -52,10 +52,21 @@ export const hideLoader = () => {
if (dom.loaderModal) dom.loaderModal.classList.add('hidden');
};
-export const showAlert = (title: any, message: any) => {
+export const showAlert = (title: any, message: any, type: string = 'error', callback?: () => void) => {
if (dom.alertTitle) dom.alertTitle.textContent = title;
if (dom.alertMessage) dom.alertMessage.textContent = message;
if (dom.alertModal) dom.alertModal.classList.remove('hidden');
+
+ if (dom.alertOkBtn) {
+ const newOkBtn = dom.alertOkBtn.cloneNode(true) as HTMLElement;
+ dom.alertOkBtn.replaceWith(newOkBtn);
+ dom.alertOkBtn = newOkBtn;
+
+ newOkBtn.addEventListener('click', () => {
+ hideAlert();
+ if (callback) callback();
+ });
+ }
};
export const hideAlert = () => {
@@ -434,1965 +445,1863 @@ const createFileInputHTML = (options = {}) => {
export const toolTemplates = {
- split: () => `
- Split PDF
- Extract pages from a PDF using various methods.
- ${createFileInputHTML()}
-
-
-
-
Split Mode
-
- Extract by Page Range (Default)
- Split by Even/Odd Pages
- Split All Pages into Separate Files
- Select Pages Visually
- Split by Bookmarks
- Split N Times
-
-
-
-
How it works:
-
- Enter page numbers separated by commas (e.g., 2, 8, 14).
- Enter page ranges using a hyphen (e.g., 5-10).
- Combine them for complex selections (e.g., 1-3, 7, 12-15).
-
-
-
Total Pages:
-
Enter page range:
-
-
-
-
-
-
How it works:
-
This will create a new PDF containing only the even or only the odd pages from your original document.
-
-
-
-
- Odd Pages Only
-
-
-
- Even Pages Only
-
-
-
-
-
-
-
How it works:
-
Click on the page thumbnails below to select them. Click again to deselect. All selected pages will be extracted.
-
-
-
-
-
-
How it works:
-
This mode will create a separate PDF file for every single page in your document and download them together in one ZIP archive.
-
-
-
-
-
How it works:
-
Split the PDF at bookmark locations. Each bookmark will start a new PDF file.
-
-
-
Bookmark Level
-
- Level 0 (Top level only)
- Level 1
- Level 2
- Level 3
- All Levels
-
-
Select which bookmark nesting level to use for splitting
-
-
-
-
-
-
How it works:
-
Split the PDF into N equal parts. For example, a 40-page PDF with N=5 will create 8 PDFs with 5 pages each.
-
-
-
Number of Pages per Split (N)
-
-
Each resulting PDF will contain N pages (except possibly the last one)
-
-
-
-
-
-
-
- Download pages as individual files in a ZIP
-
-
-
-
Split PDF
-
-
-`,
encrypt: () => `
- Encrypt PDF
- Add 256-bit AES password protection to your PDF.
- ${createFileInputHTML()}
-
-
-
-
User Password
-
-
Required to open and view the PDF
-
-
-
Owner Password (Optional)
-
-
Allows changing permissions and removing encryption
-
+ < h2 class="text-2xl font-bold text-white mb-4" > Encrypt PDF
+ < p class="mb-6 text-gray-400" > Add 256 - bit AES password protection to your PDF.
+ ${ createFileInputHTML() }
+
+ < div id = "encrypt-options" class="hidden space-y-4 mt-6" >
+
+
User Password
+ < input required type = "password" id = "user-password-input" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" placeholder = "Password to open the PDF" >
+
Required to open and view the PDF
+
+ < div >
+
Owner Password(Optional)
+ < input type = "password" id = "owner-password-input" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" placeholder = "Password for full permissions (recommended)" >
+
Allows changing permissions and removing encryption
+
-
-
+ < !--Restriction checkboxes(shown when owner password is entered)-- >
+
+
🔒 Restrict PDF Permissions
+ < p class="text-sm text-gray-400 mb-3" > Select which actions to disable:
+ < div class="space-y-2" >
+
+
+ Disable all modifications(--modify=none)
+
+ < label class="flex items-center space-x-2" >
+
+ Disable text and image extraction(--extract=n)
+
+ < label class="flex items-center space-x-2" >
+
+ Disable all printing(--print=none)
+
+ < label class="flex items-center space-x-2" >
+
+ Disable accessibility text copying(--accessibility=n)
+
+ < label class="flex items-center space-x-2" >
+
+ Disable annotations(--annotate=n)
+
+ < label class="flex items-center space-x-2" >
+
+ Disable page assembly(--assemble=n)
+
+ < label class="flex items-center space-x-2" >
+
+ Disable form filling(--form=n)
+
+ < label class="flex items-center space-x-2" >
+
+ Disable other modifications(--modify - other=n)
+
+
+
-
-
⚠️ Security Recommendation
-
For strong security, set both passwords. Without an owner password, the security restrictions (printing, copying, etc.) can be easily bypassed.
-
-
-
✓ High-Quality Encryption
-
256-bit AES encryption without quality loss. Text remains selectable and searchable.
-
-
Encrypt & Download
-
-`,
+ < div class="p-4 bg-yellow-900/20 border border-yellow-500/30 text-yellow-200 rounded-lg" >
+
⚠️ Security Recommendation
+ < p class="text-sm text-gray-300" > For strong security, set both passwords.Without an owner password, the security restrictions(printing, copying, etc.) can be easily bypassed.
+
+ < div class="p-4 bg-green-900/20 border border-green-500/30 text-green-200 rounded-lg" >
+ ✓ High - Quality Encryption
+ < p class="text-sm text-gray-300" > 256 - bit AES encryption without quality loss.Text remains selectable and searchable.
+
+ < button id = "process-btn" class="btn-gradient w-full mt-6" > Encrypt & Download
+
+ `,
decrypt: () => `
- Decrypt PDF
- Upload an encrypted PDF and provide its password to create an unlocked version.
- ${createFileInputHTML()}
-
-
-
- Enter PDF Password
-
-
-
Decrypt & Download
-
-
- `,
+ < h2 class="text-2xl font-bold text-white mb-4" > Decrypt PDF
+ < p class="mb-6 text-gray-400" > Upload an encrypted PDF and provide its password to create an unlocked version.
+ ${ createFileInputHTML() }
+
+ < div id = "decrypt-options" class="hidden space-y-4 mt-6" >
+
+ Enter PDF Password
+ < input type = "password" id = "password-input" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" placeholder = "Enter the current password" >
+
+ < button id = "process-btn" class="btn-gradient w-full mt-6" > Decrypt & Download
+
+ < canvas id = "pdf-canvas" class="hidden" >
+ `,
organize: () => `
- Organize PDF
- Reorder, rotate, or delete pages. Drag and drop pages to reorder them.
- ${createFileInputHTML()}
-
-
- Save Changes
- `,
+ < h2 class="text-2xl font-bold text-white mb-4" > Organize PDF
+ < p class="mb-6 text-gray-400" > Reorder, rotate, or delete pages.Drag and drop pages to reorder them.
+ ${ createFileInputHTML() }
+
+ < div id = "page-organizer" class="hidden grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-4 my-6" >
+ < button id = "process-btn" class="btn-gradient w-full mt-6" > Save Changes
+ `,
rotate: () => `
- Rotate PDF
- Rotate all or specific pages in a PDF document.
- ${createFileInputHTML()}
-
-
-
-
-
BATCH ACTIONS
-
-
-
-
-
Rotate By 90 degrees
-
-
-
- Left
-
-
-
- Right
-
-
-
-
-
-
-
-
-
Rotate By Custom Degrees
-
-
-
-
+ < h2 class="text-2xl font-bold text-white mb-4" > Rotate PDF
+ < p class="mb-6 text-gray-400" > Rotate all or specific pages in a PDF document.
+ ${ createFileInputHTML() }
+
-
- Apply
-
-
-
+ < div id = "rotate-all-controls" class="hidden my-6" >
+
+
BATCH ACTIONS
+ < div class="flex flex-col md:flex-row justify-center gap-6 items-center" >
-
-
-
-
-
Save Rotations
- `,
+
-
-
-
-
-
- Font Size
-
-
-
- Page Size
-
-
- A4 (210 x 297 mm)
- A3 (297 x 420 mm)
- A5 (148 x 210 mm)
- A6 (105 x 148 mm)
-
-
- Letter (8.5 x 11 in)
- Legal (8.5 x 14 in)
- Tabloid (11 x 17 in)
- Executive (7.25 x 10.5 in)
-
-
- B4 (250 x 353 mm)
- B5 (176 x 250 mm)
-
- Custom Size
-
-
-
- Orientation
-
- Portrait
- Landscape
-
-
-
-
- Text Color
-
-
-
- Create PDF
- `,
+ Select Languages
+ < div class="relative" >
+
+ English(Default)
+ < i data - lucide="chevron-down" class="w-4 h-4" >
+
+ < div id = "lang-dropdown-content" class="hidden absolute z-10 w-full bg-gray-800 border border-gray-600 rounded-lg mt-1 max-h-60 overflow-y-auto shadow-lg" >
+
+
+
+ < div id = "language-list-container" class="p-2 space-y-1" >
+
-
+ < div id = "dimensions-results" class="hidden mt-6" >
+
-
-
- Display Units:
-
- Points (pt)
- Inches (in)
- Millimeters (mm)
- Pixels (at 96 DPI)
-
-
-
-
+ < !--Controls Row-- >
+
+
+ Display Units:
+ < select id = "units-select" class="bg-gray-700 border border-gray-600 text-white rounded-lg p-2" >
+ Points(pt)
+ < option value = "in" > Inches(in)
+ < option value = "mm" > Millimeters(mm)
+ < option value = "px" > Pixels(at 96 DPI)
+
+
+ < button id = "export-csv-btn" class="btn bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2" >
+
Export to CSV
-
-
+
+
-
-
-
-
-
- Page #
- Dimensions (W x H)
- Standard Size
- Orientation
- Aspect Ratio
- Area
- Rotation
-
-
-
-
-
-
-
- `,
+ < !--Dimensions Table-- >
+
+
+
+
+ Page #
+ < th class="px-4 py-3 font-medium text-white" > Dimensions(W x H)
+ < th class="px-4 py-3 font-medium text-white" > Standard Size
+ < th class="px-4 py-3 font-medium text-white" > Orientation
+ < th class="px-4 py-3 font-medium text-white" > Aspect Ratio
+ < th class="px-4 py-3 font-medium text-white" > Area
+ < th class="px-4 py-3 font-medium text-white" > Rotation
+
+
+ < tbody id = "dimensions-table-body" class="divide-y divide-gray-700" >
+
+
+
+
+ `,
'n-up': () => `
- N-Up Page Arrangement
- Combine multiple pages from your PDF onto a single sheet. This is great for creating booklets or proof sheets.
- ${createFileInputHTML()}
-
+ < h2 class="text-2xl font-bold text-white mb-4" > N - Up Page Arrangement
+ < p class="mb-6 text-gray-400" > Combine multiple pages from your PDF onto a single sheet.This is great for creating booklets or proof sheets.
+ ${ createFileInputHTML() }
+
-
-
-
- Pages Per Sheet
-
- 2-Up
- 4-Up (2x2)
- 9-Up (3x3)
- 16-Up (4x4)
-
-
-
- Output Page Size
-
- Letter (8.5 x 11 in)
- Legal (8.5 x 14 in)
- Tabloid (11 x 17 in)
- A4 (210 x 297 mm)
- A3 (297 x 420 mm)
-
-
-
+ < div id = "n-up-options" class="hidden mt-6 space-y-4" >
+
+
+ Pages Per Sheet
+ < select id = "pages-per-sheet" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" >
+ 2 - Up
+ < option value = "4" selected > 4 - Up(2x2)
+ < option value = "9" > 9 - Up(3x3)
+ < option value = "16" > 16 - Up(4x4)
+
+
+ < div >
+
Output Page Size
+ < select id = "output-page-size" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" >
+
Letter(8.5 x 11 in)
+ < option value = "Legal" > Legal(8.5 x 14 in)
+ < option value = "Tabloid" > Tabloid(11 x 17 in)
+ < option value = "A4" selected > A4(210 x 297 mm)
+ < option value = "A3" > A3(297 x 420 mm)
+
+
+
-
-
- Output Orientation
-
- Automatic
- Portrait
- Landscape
-
-
-
-
-
- Add Margins & Gutters
-
-
-
+ < div class="grid grid-cols-1 sm:grid-cols-2 gap-4" >
+
+ Output Orientation
+ < select id = "output-orientation" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" >
+ Automatic
+ < option value = "portrait" > Portrait
+ < option value = "landscape" > Landscape
+
+
+ < div class="flex items-end pb-1" >
+
+
+ Add Margins & Gutters
+
+
+
-
+ < div class="border-t border-gray-700 pt-4 grid grid-cols-1 sm:grid-cols-2 gap-4" >
+
+
+
+ Draw Border Around Each Page
+
+
+ < div id = "border-color-wrapper" class="hidden" >
+ Border Color
+ < input type = "color" id = "border-color" value = "#000000" class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer" >
+
+
- Create N-Up PDF
-
- `,
+ < button id = "process-btn" class="btn-gradient w-full mt-6" > Create N - Up PDF
+
+ `,
'duplicate-organize': () => `
- Page Manager
- Drag pages to reorder them. Use the icon to duplicate a page or the icon to delete it.
- ${createFileInputHTML()}
-
+ < h2 class="text-2xl font-bold text-white mb-4" > Page Manager
+ < p class="mb-6 text-gray-400" > Drag pages to reorder them.Use the < i data - lucide="copy-plus" class="inline-block w-4 h-4 text-green-400" > icon to duplicate a page or the icon to delete it.
+ ${ createFileInputHTML() }
+
-
-
+ < div id = "page-manager-options" class="hidden mt-6" >
+
+
+ < button id = "process-btn" class="btn-gradient w-full mt-6" > Save New PDF
-
Save New PDF
-
- `,
+ `,
'combine-single-page': () => `
- Combine to a Single Page
- Stitch all pages of your PDF together vertically or horizontally to create one continuous page.
- ${createFileInputHTML()}
-
+ < h2 class="text-2xl font-bold text-white mb-4" > Combine to a Single Page
+ < p class="mb-6 text-gray-400" > Stitch all pages of your PDF together vertically or horizontally to create one continuous page.
+ ${ createFileInputHTML() }
+
-
-
- Orientation
-
- Vertical (Stack pages top to bottom)
- Horizontal (Stack pages left to right)
-
-
-
-
-
-
-
-
- Draw a separator line between pages
-
-
-
-
-
-
Combine Pages
-
- `,
+ < div id = "combine-options" class="hidden mt-6 space-y-4" >
+
+ Orientation
+ < select id = "combine-orientation" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" >
+ Vertical(Stack pages top to bottom)
+ < option value = "horizontal" > Horizontal(Stack pages left to right)
+
+
+
+ < div class="grid grid-cols-1 sm:grid-cols-2 gap-4" >
+
+ Spacing Between Pages(in points)
+ < input type = "number" id = "page-spacing" value = "18" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" >
+
+ < div >
+ Background Color
+ < input type = "color" id = "background-color" value = "#FFFFFF" class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer" >
+
+
+
+ < div >
+
+
+ Draw a separator line between pages
+
+
+
+ < div id = "separator-options" class="hidden grid grid-cols-1 sm:grid-cols-2 gap-4 p-4 rounded-lg bg-gray-900 border border-gray-700" >
+
+ Separator Line Thickness(in points)
+ < input type = "number" id = "separator-thickness" value = "0.5" min = "0.1" max = "10" step = "0.1" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" >
+
+ < div >
+ Separator Line Color
+ < input type = "color" id = "separator-color" value = "#CCCCCC" class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer" >
+
+
+
+ < button id = "process-btn" class="btn-gradient w-full mt-6" > Combine Pages
+
+ `,
'fix-dimensions': () => `
- Standardize Page Dimensions
- Convert all pages in your PDF to a uniform size. Choose a standard format or define a custom dimension.
- ${createFileInputHTML()}
-
-
-
-
-
- Target Size
-
- A4
- Letter
- Legal
- Tabloid
- A3
- A5
- Custom Size...
-
-
-
- Orientation
-
- Portrait
- Landscape
-
-
-
-
-
-
- Width
-
-
-
- Height
-
-
-
- Units
-
- Inches
- Millimeters
-
-
-
+ < h2 class="text-2xl font-bold text-white mb-4" > Standardize Page Dimensions
+ < p class="mb-6 text-gray-400" > Convert all pages in your PDF to a uniform size.Choose a standard format or define a custom dimension.
+ ${ createFileInputHTML() }
+
+ < div id = "fix-dimensions-options" class="hidden mt-6 space-y-4" >
+
-
Content Scaling Method
-
-
-
-
-
Fit
-
Preserves all content, may add white bars.
-
-
-
-
-
-
Fill
-
Covers the page, may crop content.
-
-
-
-
+
Target Size
+ < select id = "target-size" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" >
+
A4
+ < option value = "Letter" > Letter
+ < option value = "Legal" > Legal
+ < option value = "Tabloid" > Tabloid
+ < option value = "A3" > A3
+ < option value = "A5" > A5
+ < option value = "Custom" > Custom Size...
+
+
+ < div >
+
Orientation
+ < select id = "orientation" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" >
+
Portrait
+ < option value = "landscape" > Landscape
+
+
+
-
- Background Color (for 'Fit' mode)
-
-
+ < div id = "custom-size-wrapper" class="hidden p-4 rounded-lg bg-gray-900 border border-gray-700 grid grid-cols-3 gap-3" >
+
+ Width
+ < input type = "number" id = "custom-width" value = "8.5" class="w-full bg-gray-700 border-gray-600 text-white rounded-lg p-2" >
+
+ < div >
+ Height
+ < input type = "number" id = "custom-height" value = "11" class="w-full bg-gray-700 border-gray-600 text-white rounded-lg p-2" >
+
+ < div >
+ Units
+ < select id = "custom-units" class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2" >
+ Inches
+ < option value = "mm" > Millimeters
+
+
+
- Standardize Pages
-
- `,
+ < div >
+ Content Scaling Method
+ < div class="flex gap-4 p-2 rounded-lg bg-gray-900" >
+
+
+
+ Fit
+ < p class="text-xs text-gray-400" > Preserves all content, may add white bars.
+
+
+ < label class="flex-1 flex items-center gap-2 p-3 rounded-md hover:bg-gray-700 cursor-pointer" >
+
+
+ Fill
+ < p class="text-xs text-gray-400" > Covers the page, may crop content.
+
+
+
+
+
+ < div >
+ Background Color(for 'Fit' mode)
+ < input type = "color" id = "background-color" value = "#FFFFFF" class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer" >
+
+
+ < button id = "process-btn" class="btn-gradient w-full mt-6" > Standardize Pages
+
+ `,
'change-background-color': () => `
- Change Background Color
- Select a new background color for every page of your PDF.
- ${createFileInputHTML()}
-
-
- Choose Background Color
-
- Apply Color & Download
-
- `,
+ < h2 class="text-2xl font-bold text-white mb-4" > Change Background Color
+ < p class="mb-6 text-gray-400" > Select a new background color for every page of your PDF.
+ ${ createFileInputHTML() }
+
+ < div id = "change-background-color-options" class="hidden mt-6" >
+ Choose Background Color
+ < input type = "color" id = "background-color" value = "#FFFFFF" class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer" >
+ Apply Color & Download
+
+ `,
'change-text-color': () => `
- Change Text Color
- Change the color of dark text in your PDF. This process converts pages to images, so text will not be selectable in the final file.
- ${createFileInputHTML()}
-
-
-
- Select Text Color
-
-
-
-
-
Original
-
+ < h2 class="text-2xl font-bold text-white mb-4" > Change Text Color
+ < p class="mb-6 text-gray-400" > Change the color of dark text in your PDF.This process converts pages to images, so text will not be selectable in the final file.
+ ${ createFileInputHTML() }
+
+ < div id = "text-color-options" class="hidden mt-6 space-y-4" >
+
+ Select Text Color
+ < input type = "color" id = "text-color-input" value = "#FF0000" class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer" >
-
-
Preview
-
-
-
-
Apply Color & Download
-
- `,
+ < div class="grid grid-cols-2 gap-4" >
+
+
Original
+ < canvas id = "original-canvas" class="w-full h-auto rounded-lg border-2 border-gray-600" >
+
+ < div class="text-center" >
+
Preview
+ < canvas id = "text-color-canvas" class="w-full h-auto rounded-lg border-2 border-gray-600" >
+
+
+ < button id = "process-btn" class="btn-gradient w-full mt-6" > Apply Color & Download
+
+ `,
'compare-pdfs': () => `
- Compare PDFs
- Upload two files to visually compare them using either an overlay or a side-by-side view.
-
-
-
-
-
-
Upload Original PDF
-
-
-
-
-
+ < h2 class="text-2xl font-bold text-white mb-4" > Compare PDFs
+ < p class="mb-6 text-gray-400" > Upload two files to visually compare them using either an overlay or a side-by - side view.
-
-
-
-
Page 1 of 1
-
-
-
- Overlay
- Side-by-Side
-
-
-
- Flicker
- Opacity:
-
-
-
-
-
- Sync Scrolling
-
-
-
-
-
- `,
+ < div id = "compare-upload-area" class="grid grid-cols-1 md:grid-cols-2 gap-4" >
+
+
+
+ < p class="mb-2 text-sm text-gray-400" > Upload Original PDF < /span>
+
+ < input id = "file-input-1" type = "file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" accept = "application/pdf" >
+
+ < div id = "drop-zone-2" class="relative flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-600 rounded-xl cursor-pointer bg-gray-900 hover:bg-gray-700" >
+
+
+ < p class="mb-2 text-sm text-gray-400" > Upload Revised PDF < /span>
+
+ < input id = "file-input-2" type = "file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" accept = "application/pdf" >
+
+
+
+ < div id = "compare-viewer" class="hidden mt-6" >
+
+
+
Page < span id = "current-page-display-compare" > 1 < /span> of 1
+ < button id = "next-page-compare" class="btn p-2 rounded-full bg-gray-700 hover:bg-gray-600 disabled:opacity-50" >
+
+ < div class="bg-gray-700 p-1 rounded-md flex gap-1" >
+
Overlay
+ < button id = "view-mode-side" class="btn px-3 py-1 rounded text-sm font-semibold" > Side - by - Side
+
+ < div class="border-l border-gray-600 h-6 mx-2" >
+ < div id = "overlay-controls" class="flex items-center gap-2" >
+ Flicker
+ < label for= "opacity-slider" class= "text-sm font-medium text-gray-300" > Opacity:
+ < input type = "range" id = "opacity-slider" min = "0" max = "1" step = "0.05" value = "0.5" class="w-24" >
+
+ < div id = "side-by-side-controls" class="hidden flex items-center gap-2" >
+
+
+ Sync Scrolling
+
+
+
+ < div id = "compare-viewer-wrapper" class="compare-viewer-wrapper overlay-mode" >
+
+
+
+
+ `,
'ocr-pdf': () => `
- OCR PDF
- Convert scanned PDFs into searchable documents. Select one or more languages present in your file for the best results.
+ < h2 class="text-2xl font-bold text-white mb-4" > OCR PDF
+ < p class="mb-6 text-gray-400" > Convert scanned PDFs into searchable documents.Select one or more languages present in your file for the best results.
+
+ < div class="p-3 bg-gray-900 rounded-lg border border-gray-700 mb-6" >
+ How it works:
+
+ Extract Text: Uses Tesseract OCR to recognize text from scanned images or PDFs.
+ Searchable Output: Creates a new PDF with an invisible text layer, making your document fully searchable while preserving the original appearance.
+ Character Filtering: Use whitelists to filter out unwanted characters and improve accuracy for specific document types (invoices, forms, etc.).
+ Multi - language Support: Select multiple languages for documents containing mixed language content.
+
+
-
-
How it works:
-
- Extract Text: Uses Tesseract OCR to recognize text from scanned images or PDFs.
- Searchable Output: Creates a new PDF with an invisible text layer, making your document fully searchable while preserving the original appearance.
- Character Filtering: Use whitelists to filter out unwanted characters and improve accuracy for specific document types (invoices, forms, etc.).
- Multi-language Support: Select multiple languages for documents containing mixed language content.
-
-
-
- ${createFileInputHTML()}
-
-
-
+ ${ createFileInputHTML() }
+
+
+ < div id = "ocr-options" class="hidden mt-6 space-y-4" >
-
Languages in Document
-
-
-
-
-
- Advanced Settings (Recommended to improve accuracy)
-
-
-
-
-
- Resolution
-
- Standard (192 DPI)
- High (288 DPI)
- Ultra (384 DPI)
-
-
-
-
-
- Binarize Image (Enhance Contrast for Clean Scans)
-
-
-
-
-
Character Whitelist Preset
-
- None (All characters)
- Alphanumeric + Basic Punctuation
- Numbers + Currency Symbols
- Letters Only (A-Z, a-z)
- Numbers Only (0-9)
- Invoice/Receipt (Numbers, $, ., -, /)
- Forms (Alphanumeric + Common Symbols)
- Custom...
-
-
Only these characters will be recognized. Leave empty for all characters.
-
-
-
-
-
Character Whitelist (Optional)
-
-
Only these characters will be recognized. Leave empty for all characters.
-
-
-
-
-
Start OCR
+ )
+ .join('')
+}
+
+ < p class="text-xs text-gray-500 mt-1" > Selected: None < /span>
+
-
+ < !--Advanced settings section-- >
+
+
+ Advanced Settings(Recommended to improve accuracy)
+ < i data - lucide="chevron-down" class="w-4 h-4 transition-transform details-icon" >
+
+ < div class="mt-4 space-y-4" >
+
-
-
-
-
-
- Flatten PDF (use the Save button below)
-
-
+ < h2 class="text-2xl font-bold text-white mb-4" > Sign PDF
+ < p class="mb-6 text-gray-400" > Upload a PDF to sign it using the built-in PDF.js viewer.Look for the < strong > signature / pen tool < /strong> in the toolbar to add your signature.
+ ${ createFileInputHTML() }
+ < div id = "file-display-area" class="mt-4 space-y-2" >
- Save & Download Signed PDF
-
-`,
+ < div id = "signature-editor" class="hidden mt-6" >
+
+
-
- Save & Download Filled Form
-
-`,
+ ${ createFileInputHTML() }
+
+ < div id = "form-filler-options" class="hidden mt-6" >
+
+
+
+
+
+
+
+
+
+
+
+ Back to Tools
+
+
+
Split PDF
+
+ Extract pages from a PDF using various methods.
+
+
+
+
+
+
+
Click to select a file or
+ drag and
+ drop
+
A single PDF file
+
Your files never leave your device.
+
+
+
+
+
+
+
+
+
Split Mode
+
+ Extract by Page Range (Default)
+ Split by Even/Odd Pages
+ Split All Pages into Separate Files
+ Select Pages Visually
+ Split by Bookmarks
+ Split N Times
+
+
+
+
+
How it works:
+
+ Enter page numbers separated by commas (e.g., 2, 8, 14).
+ Enter page ranges using a hyphen (e.g., 5-10).
+ Combine them for complex selections (e.g., 1-3, 7, 12-15).
+
+
+
Page Range
+
+
+
+
+
+
How it works:
+
+ Extract all even pages (2, 4, 6...) or all odd pages (1, 3, 5...) into a new PDF.
+
+
+
+
+ Even Pages
+
+
+
+ Odd Pages
+
+
+
+
+
+
How it works:
+
+ Every single page of the PDF will be saved as a separate PDF file.
+ The result will be downloaded as a ZIP file containing all the pages.
+
+
+
+
+
+
+
How it works:
+
+ Click on the page thumbnails below to select the pages you want to extract.
+ Selected pages will be highlighted.
+
+
+
+
+
+
+
+
+
+
How it works:
+
+ Split the PDF based on its bookmarks (outline).
+ Select the bookmark level to split at.
+
+
+
Bookmark
+ Level
+
+ All Levels
+ Level 0 (Top Level Only)
+ Level 1
+ Level 2
+ Level 3
+
+
+
+
+
+
How it works:
+
+ Split the PDF into multiple files, each containing N pages.
+
+
+
Pages per file
+ (N)
+
+
+
+
+
+
+
+
+
+ Download as ZIP (for
+ multiple files)
+
+
+
Split PDF
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
BentoPDF
+
+
+ © 2025 BentoPDF. All rights reserved.
+
+
+ Version
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+