mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 11:52:48 +01:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b29c3097 | ||
|
|
90998edb25 | ||
|
|
84848ab902 | ||
|
|
2aaea50031 | ||
|
|
5fbf48601e | ||
|
|
4cec7bbe58 | ||
|
|
e505c70b2f | ||
|
|
841428f2e2 | ||
|
|
957b78fafd | ||
|
|
34f12655c1 | ||
|
|
74d7af283e | ||
|
|
5ef73edea2 | ||
|
|
9b099c6a6d |
@@ -75,7 +75,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
suffix: "-simple"
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -270,14 +270,14 @@ jobs:
|
||||
suffix: "-simple"
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
+5
-6
@@ -47,14 +47,13 @@ ENV VITE_BRAND_NAME=$VITE_BRAND_NAME
|
||||
ENV VITE_BRAND_LOGO=$VITE_BRAND_LOGO
|
||||
ENV VITE_FOOTER_TEXT=$VITE_FOOTER_TEXT
|
||||
|
||||
ARG VITE_CORS_PROXY_URL
|
||||
ARG VITE_CORS_PROXY_SECRET
|
||||
ENV VITE_CORS_PROXY_URL=$VITE_CORS_PROXY_URL
|
||||
ENV VITE_CORS_PROXY_SECRET=$VITE_CORS_PROXY_SECRET
|
||||
|
||||
ENV NODE_OPTIONS="--max-old-space-size=3072"
|
||||
|
||||
RUN npm run build:with-docs
|
||||
RUN --mount=type=secret,id=VITE_CORS_PROXY_URL \
|
||||
--mount=type=secret,id=VITE_CORS_PROXY_SECRET \
|
||||
VITE_CORS_PROXY_URL=$(cat /run/secrets/VITE_CORS_PROXY_URL 2>/dev/null || echo "") \
|
||||
VITE_CORS_PROXY_SECRET=$(cat /run/secrets/VITE_CORS_PROXY_SECRET 2>/dev/null || echo "") \
|
||||
npm run build:with-docs
|
||||
|
||||
# Production stage
|
||||
FROM quay.io/nginx/nginx-unprivileged:stable-alpine-slim
|
||||
|
||||
+5
-6
@@ -44,14 +44,13 @@ ENV VITE_BRAND_NAME=$VITE_BRAND_NAME
|
||||
ENV VITE_BRAND_LOGO=$VITE_BRAND_LOGO
|
||||
ENV VITE_FOOTER_TEXT=$VITE_FOOTER_TEXT
|
||||
|
||||
ARG VITE_CORS_PROXY_URL
|
||||
ARG VITE_CORS_PROXY_SECRET
|
||||
ENV VITE_CORS_PROXY_URL=$VITE_CORS_PROXY_URL
|
||||
ENV VITE_CORS_PROXY_SECRET=$VITE_CORS_PROXY_SECRET
|
||||
|
||||
ENV NODE_OPTIONS="--max-old-space-size=3072"
|
||||
|
||||
RUN npm run build:with-docs
|
||||
RUN --mount=type=secret,id=VITE_CORS_PROXY_URL \
|
||||
--mount=type=secret,id=VITE_CORS_PROXY_SECRET \
|
||||
VITE_CORS_PROXY_URL=$(cat /run/secrets/VITE_CORS_PROXY_URL 2>/dev/null || echo "") \
|
||||
VITE_CORS_PROXY_SECRET=$(cat /run/secrets/VITE_CORS_PROXY_SECRET 2>/dev/null || echo "") \
|
||||
npm run build:with-docs
|
||||
|
||||
# Production stage — uses standard nginx (starts as root, drops to PUID/PGID)
|
||||
FROM nginx:stable-alpine-slim
|
||||
|
||||
@@ -873,7 +873,10 @@ The **Digital Signature** tool uses a signing library that may need to fetch cer
|
||||
```
|
||||
Or with Docker:
|
||||
```bash
|
||||
docker build --build-arg VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev" -t your-bentopdf .
|
||||
export VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev"
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \
|
||||
-t your-bentopdf .
|
||||
```
|
||||
|
||||
#### Production Security Features
|
||||
@@ -927,6 +930,14 @@ npx wrangler secret put PROXY_SECRET
|
||||
|
||||
# Set in build environment
|
||||
VITE_CORS_PROXY_SECRET=your-secret npm run build
|
||||
|
||||
# Or with Docker (optional; URL secret also shown for completeness)
|
||||
export VITE_CORS_PROXY_URL="https://your-worker-url.workers.dev"
|
||||
export VITE_CORS_PROXY_SECRET="your-secret"
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \
|
||||
--secret id=VITE_CORS_PROXY_SECRET,env=VITE_CORS_PROXY_SECRET \
|
||||
-t your-bentopdf .
|
||||
```
|
||||
|
||||
### 📦 Version Management
|
||||
|
||||
@@ -142,4 +142,13 @@ npx wrangler deploy
|
||||
VITE_CORS_PROXY_URL=https://your-worker.workers.dev npm run build
|
||||
```
|
||||
|
||||
Or with Docker:
|
||||
|
||||
```bash
|
||||
export VITE_CORS_PROXY_URL="https://your-worker.workers.dev"
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \
|
||||
-t your-bentopdf .
|
||||
```
|
||||
|
||||
> **Note:** See [README](https://github.com/alam00000/bentopdf#digital-signature-cors-proxy-required) for HMAC signature setup.
|
||||
|
||||
@@ -64,8 +64,9 @@ If you're self-hosting BentoPDF, you'll need to deploy your own CORS proxy for d
|
||||
If using Docker:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg VITE_CORS_PROXY_URL="https://your-worker.workers.dev" \
|
||||
export VITE_CORS_PROXY_URL="https://your-worker.workers.dev"
|
||||
DOCKER_BUILDKIT=1 docker build \
|
||||
--secret id=VITE_CORS_PROXY_URL,env=VITE_CORS_PROXY_URL \
|
||||
-t your-bentopdf .
|
||||
```
|
||||
|
||||
|
||||
Generated
+795
-1133
File diff suppressed because it is too large
Load Diff
+28
-28
@@ -37,32 +37,32 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@types/blob-stream": "^0.1.33",
|
||||
"@types/html2canvas": "^1.0.0",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"@types/pdfkit": "^0.17.5",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@types/utif": "^3.0.6",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.39.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "^4.0.18",
|
||||
"eslint": "^10.0.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"globals": "^17.0.0",
|
||||
"globals": "^17.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.0.1",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.6.2",
|
||||
"jsdom": "^28.1.0",
|
||||
"lint-staged": "^16.3.1",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.51.0",
|
||||
"vite": "^7.1.11",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-plugin-handlebars": "^2.0.0",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vite-plugin-node-polyfills": "^0.25.0",
|
||||
"vitepress": "^1.6.4",
|
||||
"vitest": "^3.2.4",
|
||||
"vue": "^3.5.26"
|
||||
"vitest": "^4.0.18",
|
||||
"vue": "^3.5.29"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/cedarville-cursive": "^5.2.7",
|
||||
@@ -72,32 +72,32 @@
|
||||
"@fontsource/kalam": "^5.2.8",
|
||||
"@fontsource/lato": "^5.2.7",
|
||||
"@fontsource/merriweather": "^5.2.11",
|
||||
"@kenjiuno/msgreader": "^1.27.1-alpha.1",
|
||||
"@matbee/libreoffice-converter": "^2.3.1",
|
||||
"@kenjiuno/msgreader": "^1.28.0",
|
||||
"@matbee/libreoffice-converter": "^2.5.0",
|
||||
"@neslinesli93/qpdf-wasm": "^0.3.0",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"@retejs/lit-plugin": "^2.0.7",
|
||||
"@tailwindcss/vite": "^4.1.15",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"archiver": "^7.0.1",
|
||||
"blob-stream": "^0.1.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"cropperjs": "^1.6.1",
|
||||
"cropperjs": "^1.6.2",
|
||||
"embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.3.0.tgz",
|
||||
"heic2any": "^0.0.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^25.7.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jspdf": "^4.2.0",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lit": "^3.3.2",
|
||||
"lucide": "^0.546.0",
|
||||
"lucide": "^0.575.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"markdown-it-abbr": "^2.0.0",
|
||||
"markdown-it-anchor": "^9.2.0",
|
||||
@@ -110,25 +110,25 @@
|
||||
"markdown-it-sup": "^2.0.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"markdown-it-toc-done-right": "^4.2.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"mermaid": "^11.12.3",
|
||||
"node-forge": "^1.3.3",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.4.296",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"pdfkit": "^0.17.2",
|
||||
"postal-mime": "^2.7.1",
|
||||
"postal-mime": "^2.7.3",
|
||||
"rete": "^2.0.6",
|
||||
"rete-area-plugin": "^2.1.5",
|
||||
"rete-connection-plugin": "^2.0.5",
|
||||
"rete-engine": "^2.1.1",
|
||||
"rete-render-utils": "^2.0.3",
|
||||
"sortablejs": "^1.15.6",
|
||||
"sortablejs": "^1.15.7",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"terser": "^5.44.0",
|
||||
"tesseract.js": "^6.0.1",
|
||||
"terser": "^5.46.0",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"tiff": "^7.1.2",
|
||||
"utif": "^3.1.0",
|
||||
"vite-plugin-static-copy": "^3.1.4",
|
||||
"vite-plugin-static-copy": "^3.2.0",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zgapdfsigner": "^2.7.5"
|
||||
},
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "إزالة الصفحات الفارغة",
|
||||
"subtitle": "اكتشاف وحذف الصفحات الفارغة تلقائيًا."
|
||||
"subtitle": "اكتشاف وحذف الصفحات الفارغة تلقائيًا.",
|
||||
"sensitivityHint": "أعلى = أكثر صرامة، فقط الصفحات الفارغة تمامًا. أقل = يسمح بصفحات تحتوي على بعض المحتوى."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "صور إلى PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Выдаліць пустыя старонкі",
|
||||
"subtitle": "Аўтаматычна выявіць і выдаліць пустыя старонкі."
|
||||
"subtitle": "Аўтаматычна выявіць і выдаліць пустыя старонкі.",
|
||||
"sensitivityHint": "Вышэй = больш строга, толькі цалкам пустыя старонкі. Ніжэй = дапускае старонкі з нейкім змесцівам."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Відарысы ў PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Fjern tomme sider",
|
||||
"subtitle": "Find og fjern automatisk tomme sider."
|
||||
"subtitle": "Find og fjern automatisk tomme sider.",
|
||||
"sensitivityHint": "Højere = strengere, kun helt tomme sider. Lavere = tillader sider med noget indhold."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Billeder til PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Leere Seiten entfernen",
|
||||
"subtitle": "Leere Seiten automatisch erkennen und löschen."
|
||||
"subtitle": "Leere Seiten automatisch erkennen und löschen.",
|
||||
"sensitivityHint": "Höher = strenger, nur rein leere Seiten. Niedriger = erlaubt Seiten mit etwas Inhalt."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Bilder zu PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Remove Blank Pages",
|
||||
"subtitle": "Automatically detect and delete blank pages."
|
||||
"subtitle": "Automatically detect and delete blank pages.",
|
||||
"sensitivityHint": "Higher = stricter, only purely blank pages. Lower = allows pages with some content."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Images to PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Eliminar Páginas en Blanco",
|
||||
"subtitle": "Detecta y elimina automáticamente páginas en blanco."
|
||||
"subtitle": "Detecta y elimina automáticamente páginas en blanco.",
|
||||
"sensitivityHint": "Mayor = más estricto, solo páginas completamente en blanco. Menor = permite páginas con algo de contenido."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Imágenes a PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Supprimer les pages blanches",
|
||||
"subtitle": "Détecter et supprimer automatiquement les pages vides."
|
||||
"subtitle": "Détecter et supprimer automatiquement les pages vides.",
|
||||
"sensitivityHint": "Plus élevé = plus strict, uniquement les pages vierges. Plus bas = autorise les pages avec du contenu."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Images vers PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Hapus Halaman Kosong",
|
||||
"subtitle": "Deteksi dan hapus halaman kosong secara otomatis."
|
||||
"subtitle": "Deteksi dan hapus halaman kosong secara otomatis.",
|
||||
"sensitivityHint": "Lebih tinggi = lebih ketat, hanya halaman yang benar-benar kosong. Lebih rendah = mengizinkan halaman dengan sedikit konten."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Gambar ke PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Rimuovi Pagine Vuote",
|
||||
"subtitle": "Rileva e elimina automaticamente le pagine vuote."
|
||||
"subtitle": "Rileva e elimina automaticamente le pagine vuote.",
|
||||
"sensitivityHint": "Più alto = più rigoroso, solo pagine completamente vuote. Più basso = consente pagine con qualche contenuto."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Immagini in PDF",
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"addWatermark": {
|
||||
"name": "Watermerk toevoegen",
|
||||
"subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen.",
|
||||
"applyToAllPages": "Toepassen op alle pagina's"
|
||||
"applyToAllPages": "Op alle pagina's toepassen"
|
||||
},
|
||||
"headerFooter": {
|
||||
"name": "Koptekst & Voettekst",
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Blanco pagina's Verwijderen",
|
||||
"subtitle": "Automatisch blanco pagina's detecteren en verwijderen."
|
||||
"subtitle": "Automatisch blanco pagina's detecteren en verwijderen.",
|
||||
"sensitivityHint": "Hoger = strenger, alleen volledig lege pagina's. Lager = staat pagina's met enige inhoud toe."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Afbeelding naar PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Remover Páginas em Branco",
|
||||
"subtitle": "Detecte e exclua automaticamente páginas em branco."
|
||||
"subtitle": "Detecte e exclua automaticamente páginas em branco.",
|
||||
"sensitivityHint": "Maior = mais rigoroso, apenas páginas totalmente em branco. Menor = permite páginas com algum conteúdo."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Imagem para PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Ta bort tomma sidor",
|
||||
"subtitle": "Automatiskt identifiera och ta bort tomma sidor."
|
||||
"subtitle": "Automatiskt identifiera och ta bort tomma sidor.",
|
||||
"sensitivityHint": "Högre = striktare, bara helt tomma sidor. Lägre = tillåter sidor med visst innehåll."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Bilder till PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Boş Sayfaları Kaldır",
|
||||
"subtitle": "Boş sayfaları otomatik olarak tespit edin ve silin."
|
||||
"subtitle": "Boş sayfaları otomatik olarak tespit edin ve silin.",
|
||||
"sensitivityHint": "Yüksek = daha katı, yalnızca tamamen boş sayfalar. Düşük = biraz içerik olan sayfalara izin verir."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Görselden PDF'ye",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "Xóa trang trống",
|
||||
"subtitle": "Tự động phát hiện và xóa trang trống."
|
||||
"subtitle": "Tự động phát hiện và xóa trang trống.",
|
||||
"sensitivityHint": "Cao hơn = nghiêm ngặt hơn, chỉ các trang hoàn toàn trống. Thấp hơn = cho phép trang có một số nội dung."
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "Hình ảnh sang PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "移除空白頁面",
|
||||
"subtitle": "自動偵測並刪除空白頁面。"
|
||||
"subtitle": "自動偵測並刪除空白頁面。",
|
||||
"sensitivityHint": "越高 = 越嚴格,僅偵測純空白頁面。越低 = 允許包含少量內容的頁面。"
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "圖片轉 PDF",
|
||||
|
||||
@@ -163,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "移除空白页",
|
||||
"subtitle": "自动检测并删除空白页。"
|
||||
"subtitle": "自动检测并删除空白页。",
|
||||
"sensitivityHint": "越高 = 越严格,仅检测纯空白页。越低 = 允许包含少量内容的页面。"
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "图片转 PDF",
|
||||
|
||||
@@ -343,6 +343,22 @@
|
||||
"created_at": "2026-02-27T13:07:34Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 529
|
||||
},
|
||||
{
|
||||
"name": "Olivetti",
|
||||
"id": 1502093,
|
||||
"comment_id": 3984507146,
|
||||
"created_at": "2026-03-02T13:46:02Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 531
|
||||
},
|
||||
{
|
||||
"name": "InstaZDLL",
|
||||
"id": 72951793,
|
||||
"comment_id": 3984770306,
|
||||
"created_at": "2026-03-02T14:33:11Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 538
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
-1
@@ -1,5 +1,5 @@
|
||||
// This file centralizes the definition of all available tools, organized by category.
|
||||
export const categories = [
|
||||
const baseCategories = [
|
||||
{
|
||||
name: 'Popular Tools',
|
||||
tools: [
|
||||
@@ -788,3 +788,16 @@ export const categories = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getToolIdFromHref = (href: string): string => {
|
||||
const match = href.match(/\/([^/]+)\.html$/);
|
||||
return match?.[1] ?? href;
|
||||
};
|
||||
|
||||
export const categories = baseCategories.map((category) => ({
|
||||
...category,
|
||||
tools: category.tools.map((tool) => ({
|
||||
...tool,
|
||||
id: getToolIdFromHref(tool.href),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -159,7 +159,7 @@ async function renderThumbnails() {
|
||||
|
||||
for (let i = 1; i <= deleteState.totalPages; i++) {
|
||||
const page = await deleteState.pdfJsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.3 });
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
renderPagesProgressively,
|
||||
cleanupLazyRendering,
|
||||
} from '../utils/render-utils.js';
|
||||
import { initPagePreview } from '../utils/page-preview.js';
|
||||
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
|
||||
import {
|
||||
showWasmRequiredDialog,
|
||||
@@ -210,6 +211,8 @@ async function renderPageMergeThumbnails() {
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
initPagePreview(container, pdfjsDoc);
|
||||
}
|
||||
|
||||
mergeState.cachedThumbnails = true;
|
||||
|
||||
+330
-270
@@ -1,365 +1,425 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { showAlert, showLoader, hideLoader } from '../ui.js';
|
||||
import { readFileAsArrayBuffer, formatBytes, downloadFile, getPDFDocument } from '../utils/helpers.js';
|
||||
import {
|
||||
readFileAsArrayBuffer,
|
||||
formatBytes,
|
||||
downloadFile,
|
||||
getPDFDocument,
|
||||
} from '../utils/helpers.js';
|
||||
import { initPagePreview } from '../utils/page-preview.js';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
interface OrganizeState {
|
||||
file: File | null;
|
||||
pdfDoc: any;
|
||||
pdfJsDoc: any;
|
||||
totalPages: number;
|
||||
sortableInstance: any;
|
||||
file: File | null;
|
||||
pdfDoc: any;
|
||||
pdfJsDoc: any;
|
||||
totalPages: number;
|
||||
sortableInstance: any;
|
||||
}
|
||||
|
||||
const organizeState: OrganizeState = {
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
pdfJsDoc: null,
|
||||
totalPages: 0,
|
||||
sortableInstance: null,
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
pdfJsDoc: null,
|
||||
totalPages: 0,
|
||||
sortableInstance: null,
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
} else {
|
||||
initializePage();
|
||||
initializePage();
|
||||
}
|
||||
|
||||
function initializePage() {
|
||||
createIcons({ icons });
|
||||
createIcons({ icons });
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
|
||||
if (fileInput) fileInput.addEventListener('change', handleFileUpload);
|
||||
if (fileInput) fileInput.addEventListener('change', handleFileUpload);
|
||||
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('bg-gray-700'); });
|
||||
dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('bg-gray-700'); });
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) handleFile(droppedFiles[0]);
|
||||
});
|
||||
// Clear value on click to allow re-selecting the same file
|
||||
fileInput?.addEventListener('click', () => {
|
||||
if (fileInput) fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) processBtn.addEventListener('click', saveChanges);
|
||||
|
||||
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
if (dropZone) {
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const droppedFiles = e.dataTransfer?.files;
|
||||
if (droppedFiles && droppedFiles.length > 0) handleFile(droppedFiles[0]);
|
||||
});
|
||||
// Clear value on click to allow re-selecting the same file
|
||||
fileInput?.addEventListener('click', () => {
|
||||
if (fileInput) fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
const applyOrderBtn = document.getElementById('apply-order-btn');
|
||||
if (applyOrderBtn) applyOrderBtn.addEventListener('click', applyCustomOrder);
|
||||
if (processBtn) processBtn.addEventListener('click', saveChanges);
|
||||
|
||||
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
|
||||
const applyOrderBtn = document.getElementById('apply-order-btn');
|
||||
if (applyOrderBtn) applyOrderBtn.addEventListener('click', applyCustomOrder);
|
||||
}
|
||||
|
||||
function applyCustomOrder() {
|
||||
const orderInput = document.getElementById('page-order-input') as HTMLInputElement;
|
||||
const grid = document.getElementById('page-grid');
|
||||
const orderInput = document.getElementById(
|
||||
'page-order-input'
|
||||
) as HTMLInputElement;
|
||||
const grid = document.getElementById('page-grid');
|
||||
|
||||
if (!orderInput || !grid) return;
|
||||
if (!orderInput || !grid) return;
|
||||
|
||||
const orderString = orderInput.value;
|
||||
if (!orderString) {
|
||||
showAlert('Invalid Order', 'Please enter a page order.');
|
||||
return;
|
||||
const orderString = orderInput.value;
|
||||
if (!orderString) {
|
||||
showAlert('Invalid Order', 'Please enter a page order.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrder = orderString.split(',').map((s) => parseInt(s.trim(), 10));
|
||||
|
||||
// Validation
|
||||
const currentGridCount = grid.children.length;
|
||||
const validNumbers = newOrder.every((n) => !isNaN(n) && n > 0); // Basic check, will validate against available thumbnails
|
||||
if (!validNumbers) {
|
||||
showAlert('Invalid Page Numbers', `Please enter positive numbers.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newOrder.length !== currentGridCount) {
|
||||
showAlert(
|
||||
'Incorrect Page Count',
|
||||
`The number of pages specified (${newOrder.length}) does not match the current number of pages in the document (${currentGridCount}). Please provide a complete ordering for all pages.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueNumbers = new Set(newOrder);
|
||||
if (uniqueNumbers.size !== newOrder.length) {
|
||||
showAlert(
|
||||
'Duplicate Page Numbers',
|
||||
'Please ensure all page numbers in the order are unique.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentThumbnails = Array.from(grid.children) as HTMLElement[];
|
||||
const reorderedThumbnails: HTMLElement[] = [];
|
||||
const foundIndices = new Set();
|
||||
|
||||
for (const pageNum of newOrder) {
|
||||
const originalIndexToFind = pageNum - 1; // pageNum is 1-based, originalPageIndex is 0-based
|
||||
const foundThumbnail = currentThumbnails.find(
|
||||
(thumb) =>
|
||||
thumb.dataset.originalPageIndex === originalIndexToFind.toString()
|
||||
);
|
||||
|
||||
if (foundThumbnail) {
|
||||
reorderedThumbnails.push(foundThumbnail);
|
||||
foundIndices.add(originalIndexToFind.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const newOrder = orderString.split(',').map(s => parseInt(s.trim(), 10));
|
||||
const allOriginalIndicesPresent = currentThumbnails.every((thumb) =>
|
||||
foundIndices.has(thumb.dataset.originalPageIndex)
|
||||
);
|
||||
|
||||
// Validation
|
||||
const currentGridCount = grid.children.length;
|
||||
const validNumbers = newOrder.every(n => !isNaN(n) && n > 0); // Basic check, will validate against available thumbnails
|
||||
if (!validNumbers) {
|
||||
showAlert('Invalid Page Numbers', `Please enter positive numbers.`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
reorderedThumbnails.length !== currentGridCount ||
|
||||
!allOriginalIndicesPresent
|
||||
) {
|
||||
showAlert(
|
||||
'Invalid Page Order',
|
||||
'The specified page order is incomplete or contains invalid page numbers. Please ensure you provide a new position for every original page.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newOrder.length !== currentGridCount) {
|
||||
showAlert('Incorrect Page Count', `The number of pages specified (${newOrder.length}) does not match the current number of pages in the document (${currentGridCount}). Please provide a complete ordering for all pages.`);
|
||||
return;
|
||||
}
|
||||
// Clear the grid and append the reordered thumbnails
|
||||
grid.innerHTML = '';
|
||||
reorderedThumbnails.forEach((thumb) => grid.appendChild(thumb));
|
||||
|
||||
const uniqueNumbers = new Set(newOrder);
|
||||
if (uniqueNumbers.size !== newOrder.length) {
|
||||
showAlert('Duplicate Page Numbers', 'Please ensure all page numbers in the order are unique.');
|
||||
return;
|
||||
}
|
||||
initializeSortable(); // Re-initialize sortable on the new order
|
||||
|
||||
const currentThumbnails = Array.from(grid.children) as HTMLElement[];
|
||||
const reorderedThumbnails: HTMLElement[] = [];
|
||||
const foundIndices = new Set();
|
||||
|
||||
for (const pageNum of newOrder) {
|
||||
const originalIndexToFind = pageNum - 1; // pageNum is 1-based, originalPageIndex is 0-based
|
||||
const foundThumbnail = currentThumbnails.find(
|
||||
thumb => thumb.dataset.originalPageIndex === originalIndexToFind.toString()
|
||||
);
|
||||
|
||||
if (foundThumbnail) {
|
||||
reorderedThumbnails.push(foundThumbnail);
|
||||
foundIndices.add(originalIndexToFind.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const allOriginalIndicesPresent = currentThumbnails.every(thumb => foundIndices.has(thumb.dataset.originalPageIndex));
|
||||
|
||||
if (reorderedThumbnails.length !== currentGridCount || !allOriginalIndicesPresent) {
|
||||
showAlert('Invalid Page Order', 'The specified page order is incomplete or contains invalid page numbers. Please ensure you provide a new position for every original page.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the grid and append the reordered thumbnails
|
||||
grid.innerHTML = '';
|
||||
reorderedThumbnails.forEach(thumb => grid.appendChild(thumb));
|
||||
|
||||
initializeSortable(); // Re-initialize sortable on the new order
|
||||
|
||||
showAlert('Success', 'Pages have been reordered.', 'success');
|
||||
showAlert('Success', 'Pages have been reordered.', 'success');
|
||||
}
|
||||
|
||||
function handleFileUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) handleFile(input.files[0]);
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) handleFile(input.files[0]);
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
showAlert('Invalid File', 'Please select a PDF file.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
file.type !== 'application/pdf' &&
|
||||
!file.name.toLowerCase().endsWith('.pdf')
|
||||
) {
|
||||
showAlert('Invalid File', 'Please select a PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader('Loading PDF...');
|
||||
organizeState.file = file;
|
||||
showLoader('Loading PDF...');
|
||||
organizeState.file = file;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
organizeState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false });
|
||||
organizeState.pdfJsDoc = await getPDFDocument({ data: (arrayBuffer as ArrayBuffer).slice(0) }).promise;
|
||||
organizeState.totalPages = organizeState.pdfDoc.getPageCount();
|
||||
try {
|
||||
const arrayBuffer = await readFileAsArrayBuffer(file);
|
||||
organizeState.pdfDoc = await PDFDocument.load(arrayBuffer as ArrayBuffer, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
organizeState.pdfJsDoc = await getPDFDocument({
|
||||
data: (arrayBuffer as ArrayBuffer).slice(0),
|
||||
}).promise;
|
||||
organizeState.totalPages = organizeState.pdfDoc.getPageCount();
|
||||
|
||||
updateFileDisplay();
|
||||
await renderThumbnails();
|
||||
hideLoader();
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
}
|
||||
updateFileDisplay();
|
||||
await renderThumbnails();
|
||||
hideLoader();
|
||||
} catch (error) {
|
||||
console.error('Error loading PDF:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileDisplay() {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (!fileDisplayArea || !organizeState.file) return;
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (!fileDisplayArea || !organizeState.file) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
fileDisplayArea.innerHTML = '';
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col flex-1 min-w-0';
|
||||
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = organizeState.file.name;
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = organizeState.file.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(organizeState.file.size)} • ${organizeState.totalPages} pages`;
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = `${formatBytes(organizeState.file.size)} • ${organizeState.totalPages} pages`;
|
||||
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => resetState();
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = () => resetState();
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function renumberPages() {
|
||||
const grid = document.getElementById('page-grid');
|
||||
if (!grid) return;
|
||||
const labels = grid.querySelectorAll('.page-number');
|
||||
labels.forEach((label, index) => {
|
||||
label.textContent = (index + 1).toString();
|
||||
});
|
||||
const grid = document.getElementById('page-grid');
|
||||
if (!grid) return;
|
||||
const labels = grid.querySelectorAll('.page-number');
|
||||
labels.forEach((label, index) => {
|
||||
label.textContent = (index + 1).toString();
|
||||
});
|
||||
}
|
||||
|
||||
function attachEventListeners(element: HTMLElement) {
|
||||
const duplicateBtn = element.querySelector('.duplicate-btn');
|
||||
const deleteBtn = element.querySelector('.delete-btn');
|
||||
const duplicateBtn = element.querySelector('.duplicate-btn');
|
||||
const deleteBtn = element.querySelector('.delete-btn');
|
||||
|
||||
duplicateBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const clone = element.cloneNode(true) as HTMLElement;
|
||||
element.after(clone);
|
||||
attachEventListeners(clone);
|
||||
renumberPages();
|
||||
createIcons({ icons });
|
||||
initializeSortable();
|
||||
});
|
||||
duplicateBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const clone = element.cloneNode(true) as HTMLElement;
|
||||
element.after(clone);
|
||||
attachEventListeners(clone);
|
||||
renumberPages();
|
||||
createIcons({ icons });
|
||||
initializeSortable();
|
||||
});
|
||||
|
||||
deleteBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const grid = document.getElementById('page-grid');
|
||||
if (grid && grid.children.length > 1) {
|
||||
element.remove();
|
||||
renumberPages();
|
||||
initializeSortable();
|
||||
} else {
|
||||
showAlert('Cannot Delete', 'You cannot delete the last page of the document.');
|
||||
}
|
||||
});
|
||||
deleteBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const grid = document.getElementById('page-grid');
|
||||
if (grid && grid.children.length > 1) {
|
||||
element.remove();
|
||||
renumberPages();
|
||||
initializeSortable();
|
||||
} else {
|
||||
showAlert(
|
||||
'Cannot Delete',
|
||||
'You cannot delete the last page of the document.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function renderThumbnails() {
|
||||
const grid = document.getElementById('page-grid');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const advancedSettings = document.getElementById('advanced-settings');
|
||||
if (!grid || !processBtn || !advancedSettings) return;
|
||||
const grid = document.getElementById('page-grid');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const advancedSettings = document.getElementById('advanced-settings');
|
||||
if (!grid || !processBtn || !advancedSettings) return;
|
||||
|
||||
grid.innerHTML = '';
|
||||
grid.classList.remove('hidden');
|
||||
processBtn.classList.remove('hidden');
|
||||
advancedSettings.classList.remove('hidden');
|
||||
grid.innerHTML = '';
|
||||
grid.classList.remove('hidden');
|
||||
processBtn.classList.remove('hidden');
|
||||
advancedSettings.classList.remove('hidden');
|
||||
|
||||
for (let i = 1; i <= organizeState.totalPages; i++) {
|
||||
const page = await organizeState.pdfJsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
for (let i = 1; i <= organizeState.totalPages; i++) {
|
||||
const page = await organizeState.pdfJsDoc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'page-thumbnail relative cursor-move flex flex-col items-center gap-2';
|
||||
wrapper.dataset.originalPageIndex = (i - 1).toString();
|
||||
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 group';
|
||||
wrapper.dataset.originalPageIndex = (i - 1).toString();
|
||||
wrapper.dataset.pageNumber = i.toString();
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className = 'w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600';
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className = 'relative';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'max-w-full max-h-full object-contain';
|
||||
imgContainer.appendChild(img);
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||
imgContainer.appendChild(img);
|
||||
|
||||
const pageLabel = document.createElement('span');
|
||||
pageLabel.className = 'page-number absolute top-1 left-1 bg-gray-900 bg-opacity-75 text-white text-xs rounded-full px-2 py-1';
|
||||
pageLabel.textContent = i.toString();
|
||||
const pageLabel = document.createElement('div');
|
||||
pageLabel.className =
|
||||
'page-number absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none';
|
||||
pageLabel.textContent = i.toString();
|
||||
imgContainer.appendChild(pageLabel);
|
||||
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className = 'flex items-center justify-center gap-4';
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className = 'flex items-center justify-center gap-4';
|
||||
|
||||
const duplicateBtn = document.createElement('button');
|
||||
duplicateBtn.className = 'duplicate-btn bg-green-600 hover:bg-green-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
||||
duplicateBtn.title = 'Duplicate Page';
|
||||
duplicateBtn.innerHTML = '<i data-lucide="copy-plus" class="w-5 h-5"></i>';
|
||||
const duplicateBtn = document.createElement('button');
|
||||
duplicateBtn.className =
|
||||
'duplicate-btn bg-green-600 hover:bg-green-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
||||
duplicateBtn.title = 'Duplicate Page';
|
||||
duplicateBtn.innerHTML = '<i data-lucide="copy-plus" class="w-5 h-5"></i>';
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'delete-btn bg-red-600 hover:bg-red-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
||||
deleteBtn.title = 'Delete Page';
|
||||
deleteBtn.innerHTML = '<i data-lucide="x-circle" class="w-5 h-5"></i>';
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className =
|
||||
'delete-btn bg-red-600 hover:bg-red-700 text-white rounded-full w-8 h-8 flex items-center justify-center';
|
||||
deleteBtn.title = 'Delete Page';
|
||||
deleteBtn.innerHTML = '<i data-lucide="x-circle" class="w-5 h-5"></i>';
|
||||
|
||||
controlsDiv.append(duplicateBtn, deleteBtn);
|
||||
wrapper.append(imgContainer, pageLabel, controlsDiv);
|
||||
grid.appendChild(wrapper);
|
||||
controlsDiv.append(duplicateBtn, deleteBtn);
|
||||
wrapper.append(imgContainer, controlsDiv);
|
||||
grid.appendChild(wrapper);
|
||||
|
||||
attachEventListeners(wrapper);
|
||||
}
|
||||
attachEventListeners(wrapper);
|
||||
}
|
||||
|
||||
createIcons({ icons });
|
||||
initializeSortable();
|
||||
createIcons({ icons });
|
||||
initializeSortable();
|
||||
initPagePreview(grid, organizeState.pdfJsDoc);
|
||||
}
|
||||
|
||||
function initializeSortable() {
|
||||
const grid = document.getElementById('page-grid');
|
||||
if (!grid) return;
|
||||
const grid = document.getElementById('page-grid');
|
||||
if (!grid) return;
|
||||
|
||||
if (organizeState.sortableInstance) organizeState.sortableInstance.destroy();
|
||||
if (organizeState.sortableInstance) organizeState.sortableInstance.destroy();
|
||||
|
||||
organizeState.sortableInstance = Sortable.create(grid, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
filter: '.duplicate-btn, .delete-btn',
|
||||
preventOnFilter: true,
|
||||
onStart: (evt) => {
|
||||
if (evt.item) evt.item.style.opacity = '0.5';
|
||||
},
|
||||
onEnd: (evt) => {
|
||||
if (evt.item) evt.item.style.opacity = '1';
|
||||
},
|
||||
});
|
||||
organizeState.sortableInstance = Sortable.create(grid, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
filter: '.duplicate-btn, .delete-btn',
|
||||
preventOnFilter: true,
|
||||
onStart: (evt) => {
|
||||
if (evt.item) evt.item.style.opacity = '0.5';
|
||||
},
|
||||
onEnd: (evt) => {
|
||||
if (evt.item) evt.item.style.opacity = '1';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
showLoader('Building new PDF...');
|
||||
showLoader('Building new PDF...');
|
||||
|
||||
try {
|
||||
const grid = document.getElementById('page-grid');
|
||||
if (!grid) return;
|
||||
try {
|
||||
const grid = document.getElementById('page-grid');
|
||||
if (!grid) return;
|
||||
|
||||
const finalPageElements = grid.querySelectorAll('.page-thumbnail');
|
||||
const finalIndices = Array.from(finalPageElements)
|
||||
.map(el => parseInt((el as HTMLElement).dataset.originalPageIndex || '', 10))
|
||||
.filter(index => !isNaN(index) && index >= 0);
|
||||
const finalPageElements = grid.querySelectorAll('.page-thumbnail');
|
||||
const finalIndices = Array.from(finalPageElements)
|
||||
.map((el) =>
|
||||
parseInt((el as HTMLElement).dataset.originalPageIndex || '', 10)
|
||||
)
|
||||
.filter((index) => !isNaN(index) && index >= 0);
|
||||
|
||||
if (finalIndices.length === 0) {
|
||||
showAlert('Error', 'No valid pages to save.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(organizeState.pdfDoc, finalIndices);
|
||||
copiedPages.forEach(page => newPdf.addPage(page));
|
||||
|
||||
const pdfBytes = await newPdf.save();
|
||||
const baseName = organizeState.file?.name.replace('.pdf', '') || 'document';
|
||||
downloadFile(new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }), `${baseName}_organized.pdf`);
|
||||
|
||||
hideLoader();
|
||||
showAlert('Success', 'PDF organized successfully!', 'success', () => resetState());
|
||||
} catch (error) {
|
||||
console.error('Error saving changes:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'Failed to save changes.');
|
||||
if (finalIndices.length === 0) {
|
||||
showAlert('Error', 'No valid pages to save.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newPdf = await PDFDocument.create();
|
||||
const copiedPages = await newPdf.copyPages(
|
||||
organizeState.pdfDoc,
|
||||
finalIndices
|
||||
);
|
||||
copiedPages.forEach((page) => newPdf.addPage(page));
|
||||
|
||||
const pdfBytes = await newPdf.save();
|
||||
const baseName = organizeState.file?.name.replace('.pdf', '') || 'document';
|
||||
downloadFile(
|
||||
new Blob([pdfBytes as BlobPart], { type: 'application/pdf' }),
|
||||
`${baseName}_organized.pdf`
|
||||
);
|
||||
|
||||
hideLoader();
|
||||
showAlert('Success', 'PDF organized successfully!', 'success', () =>
|
||||
resetState()
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error saving changes:', error);
|
||||
hideLoader();
|
||||
showAlert('Error', 'Failed to save changes.');
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
if (organizeState.sortableInstance) {
|
||||
organizeState.sortableInstance.destroy();
|
||||
organizeState.sortableInstance = null;
|
||||
}
|
||||
if (organizeState.sortableInstance) {
|
||||
organizeState.sortableInstance.destroy();
|
||||
organizeState.sortableInstance = null;
|
||||
}
|
||||
|
||||
organizeState.file = null;
|
||||
organizeState.pdfDoc = null;
|
||||
organizeState.pdfJsDoc = null;
|
||||
organizeState.totalPages = 0;
|
||||
organizeState.file = null;
|
||||
organizeState.pdfDoc = null;
|
||||
organizeState.pdfJsDoc = null;
|
||||
organizeState.totalPages = 0;
|
||||
|
||||
const grid = document.getElementById('page-grid');
|
||||
if (grid) {
|
||||
grid.innerHTML = '';
|
||||
grid.classList.add('hidden');
|
||||
}
|
||||
document.getElementById('process-btn')?.classList.add('hidden');
|
||||
document.getElementById('advanced-settings')?.classList.add('hidden');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
const grid = document.getElementById('page-grid');
|
||||
if (grid) {
|
||||
grid.innerHTML = '';
|
||||
grid.classList.add('hidden');
|
||||
}
|
||||
document.getElementById('process-btn')?.classList.add('hidden');
|
||||
document.getElementById('advanced-settings')?.classList.add('hidden');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
}
|
||||
|
||||
+536
-431
File diff suppressed because it is too large
Load Diff
@@ -1,65 +1,79 @@
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import { initPagePreview } from '../utils/page-preview.js';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
// State
|
||||
const pageState: {
|
||||
pdfDoc: PDFDocument | null;
|
||||
file: File | null;
|
||||
detectedBlankPages: number[];
|
||||
pageThumbnails: Map<number, string>;
|
||||
pdfDoc: PDFDocument | null;
|
||||
file: File | null;
|
||||
detectedBlankPages: number[];
|
||||
pageThumbnails: Map<number, string>;
|
||||
} = {
|
||||
pdfDoc: null,
|
||||
file: null,
|
||||
detectedBlankPages: [],
|
||||
pageThumbnails: new Map()
|
||||
pdfDoc: null,
|
||||
file: null,
|
||||
detectedBlankPages: [],
|
||||
pageThumbnails: new Map(),
|
||||
};
|
||||
|
||||
function showLoader(msg = 'Processing...') {
|
||||
document.getElementById('loader-modal')?.classList.remove('hidden');
|
||||
const txt = document.getElementById('loader-text');
|
||||
if (txt) txt.textContent = msg;
|
||||
document.getElementById('loader-modal')?.classList.remove('hidden');
|
||||
const txt = document.getElementById('loader-text');
|
||||
if (txt) txt.textContent = msg;
|
||||
}
|
||||
|
||||
function hideLoader() { document.getElementById('loader-modal')?.classList.add('hidden'); }
|
||||
function hideLoader() {
|
||||
document.getElementById('loader-modal')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function showAlert(title: string, msg: string, type = 'error', cb?: () => void) {
|
||||
const modal = document.getElementById('alert-modal');
|
||||
const t = document.getElementById('alert-title');
|
||||
const m = document.getElementById('alert-message');
|
||||
if (t) t.textContent = title;
|
||||
if (m) m.textContent = msg;
|
||||
modal?.classList.remove('hidden');
|
||||
const okBtn = document.getElementById('alert-ok');
|
||||
if (okBtn) {
|
||||
const newBtn = okBtn.cloneNode(true) as HTMLElement;
|
||||
okBtn.replaceWith(newBtn);
|
||||
newBtn.addEventListener('click', () => {
|
||||
modal?.classList.add('hidden');
|
||||
if (cb) cb();
|
||||
});
|
||||
}
|
||||
function showAlert(
|
||||
title: string,
|
||||
msg: string,
|
||||
type = 'error',
|
||||
cb?: () => void
|
||||
) {
|
||||
const modal = document.getElementById('alert-modal');
|
||||
const t = document.getElementById('alert-title');
|
||||
const m = document.getElementById('alert-message');
|
||||
if (t) t.textContent = title;
|
||||
if (m) m.textContent = msg;
|
||||
modal?.classList.remove('hidden');
|
||||
const okBtn = document.getElementById('alert-ok');
|
||||
if (okBtn) {
|
||||
const newBtn = okBtn.cloneNode(true) as HTMLElement;
|
||||
okBtn.replaceWith(newBtn);
|
||||
newBtn.addEventListener('click', () => {
|
||||
modal?.classList.add('hidden');
|
||||
if (cb) cb();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function updateFileDisplay() {
|
||||
const area = document.getElementById('file-display-area');
|
||||
if (!area || !pageState.file || !pageState.pdfDoc) return;
|
||||
const area = document.getElementById('file-display-area');
|
||||
if (!area || !pageState.file || !pageState.pdfDoc) return;
|
||||
|
||||
const fileSize = pageState.file.size < 1024 * 1024
|
||||
? `${(pageState.file.size / 1024).toFixed(1)} KB`
|
||||
: `${(pageState.file.size / 1024 / 1024).toFixed(2)} MB`;
|
||||
const pageCount = pageState.pdfDoc.getPageCount();
|
||||
const fileSize =
|
||||
pageState.file.size < 1024 * 1024
|
||||
? `${(pageState.file.size / 1024).toFixed(1)} KB`
|
||||
: `${(pageState.file.size / 1024 / 1024).toFixed(2)} MB`;
|
||||
const pageCount = pageState.pdfDoc.getPageCount();
|
||||
|
||||
area.innerHTML = `
|
||||
area.innerHTML = `
|
||||
<div class="bg-gray-700 p-3 rounded-lg border border-gray-600 hover:border-indigo-500 transition-colors">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -72,257 +86,285 @@ function updateFileDisplay() {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
createIcons({ icons });
|
||||
document.getElementById('remove-file')?.addEventListener('click', resetState);
|
||||
createIcons({ icons });
|
||||
document.getElementById('remove-file')?.addEventListener('click', resetState);
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
pageState.pdfDoc = null;
|
||||
pageState.file = null;
|
||||
pageState.detectedBlankPages = [];
|
||||
pageState.pageThumbnails.forEach(url => URL.revokeObjectURL(url));
|
||||
pageState.pageThumbnails.clear();
|
||||
pageState.pdfDoc = null;
|
||||
pageState.file = null;
|
||||
pageState.detectedBlankPages = [];
|
||||
pageState.pageThumbnails.forEach((url) => URL.revokeObjectURL(url));
|
||||
pageState.pageThumbnails.clear();
|
||||
|
||||
const area = document.getElementById('file-display-area');
|
||||
if (area) area.innerHTML = '';
|
||||
document.getElementById('options-panel')?.classList.add('hidden');
|
||||
document.getElementById('preview-panel')?.classList.add('hidden');
|
||||
const inp = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (inp) inp.value = '';
|
||||
const area = document.getElementById('file-display-area');
|
||||
if (area) area.innerHTML = '';
|
||||
document.getElementById('options-panel')?.classList.add('hidden');
|
||||
document.getElementById('preview-panel')?.classList.add('hidden');
|
||||
const inp = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (inp) inp.value = '';
|
||||
const slider = document.getElementById(
|
||||
'sensitivity-slider'
|
||||
) as HTMLInputElement;
|
||||
if (slider) slider.value = '80';
|
||||
const sliderLabel = document.getElementById('sensitivity-value');
|
||||
if (sliderLabel) sliderLabel.textContent = '80';
|
||||
}
|
||||
|
||||
async function handleFileUpload(file: File) {
|
||||
if (!file || file.type !== 'application/pdf') {
|
||||
showAlert('Error', 'Please upload a valid PDF file.');
|
||||
return;
|
||||
}
|
||||
showLoader('Loading PDF...');
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
pageState.pdfDoc = await PDFDocument.load(buf);
|
||||
pageState.file = file;
|
||||
pageState.detectedBlankPages = [];
|
||||
updateFileDisplay();
|
||||
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||
document.getElementById('preview-panel')?.classList.add('hidden');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
if (!file || file.type !== 'application/pdf') {
|
||||
showAlert('Error', 'Please upload a valid PDF file.');
|
||||
return;
|
||||
}
|
||||
showLoader('Loading PDF...');
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
pageState.pdfDoc = await PDFDocument.load(buf);
|
||||
pageState.file = file;
|
||||
pageState.detectedBlankPages = [];
|
||||
updateFileDisplay();
|
||||
document.getElementById('options-panel')?.classList.remove('hidden');
|
||||
document.getElementById('preview-panel')?.classList.add('hidden');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Failed to load PDF file.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
async function isPageBlank(page: any, threshold = 250): Promise<boolean> {
|
||||
const viewport = page.getViewport({ scale: 0.5 }); // Lower scale for faster processing
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return false;
|
||||
async function isPageBlank(
|
||||
page: any,
|
||||
maxNonWhitePercent = 0.5
|
||||
): Promise<boolean> {
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return false;
|
||||
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
const totalPixels = data.length / 4;
|
||||
|
||||
let totalBrightness = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i], g = data[i + 1], b = data[i + 2];
|
||||
totalBrightness += (r + g + b) / 3;
|
||||
}
|
||||
let nonWhitePixels = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
||||
if (brightness < 240) nonWhitePixels++;
|
||||
}
|
||||
|
||||
const avgBrightness = totalBrightness / (data.length / 4);
|
||||
return avgBrightness > threshold;
|
||||
const nonWhitePercent = (nonWhitePixels / totalPixels) * 100;
|
||||
return nonWhitePercent <= maxNonWhitePercent;
|
||||
}
|
||||
|
||||
async function generateThumbnail(page: any): Promise<string> {
|
||||
const viewport = page.getViewport({ scale: 0.3 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
return canvas.toDataURL('image/jpeg', 0.7);
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
return canvas.toDataURL('image/jpeg', 0.7);
|
||||
}
|
||||
|
||||
async function detectBlankPages() {
|
||||
if (!pageState.pdfDoc || !pageState.file) return showAlert('Error', 'Please upload a PDF first.');
|
||||
if (!pageState.pdfDoc || !pageState.file)
|
||||
return showAlert('Error', 'Please upload a PDF first.');
|
||||
|
||||
const sensitivitySlider = document.getElementById('sensitivity-slider') as HTMLInputElement;
|
||||
const sensitivityPercent = parseInt(sensitivitySlider?.value || '80');
|
||||
const threshold = Math.round(255 - (sensitivityPercent * 2.55));
|
||||
const sensitivitySlider = document.getElementById(
|
||||
'sensitivity-slider'
|
||||
) as HTMLInputElement;
|
||||
const sensitivityPercent = parseInt(sensitivitySlider?.value || '80');
|
||||
const maxNonWhitePercent = 5 - (sensitivityPercent / 100) * 4.9;
|
||||
|
||||
showLoader('Detecting blank pages...');
|
||||
try {
|
||||
const pdfData = await pageState.file.arrayBuffer();
|
||||
const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise;
|
||||
const totalPages = pdfDoc.numPages;
|
||||
showLoader('Detecting blank pages...');
|
||||
try {
|
||||
const pdfData = await pageState.file.arrayBuffer();
|
||||
const pdfDoc = await pdfjsLib.getDocument({ data: pdfData }).promise;
|
||||
const totalPages = pdfDoc.numPages;
|
||||
|
||||
pageState.detectedBlankPages = [];
|
||||
pageState.pageThumbnails.forEach(url => URL.revokeObjectURL(url));
|
||||
pageState.pageThumbnails.clear();
|
||||
pageState.detectedBlankPages = [];
|
||||
pageState.pageThumbnails.forEach((url) => URL.revokeObjectURL(url));
|
||||
pageState.pageThumbnails.clear();
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const page = await pdfDoc.getPage(i);
|
||||
if (await isPageBlank(page, threshold)) {
|
||||
pageState.detectedBlankPages.push(i - 1); // 0-indexed
|
||||
const thumbnail = await generateThumbnail(page);
|
||||
pageState.pageThumbnails.set(i - 1, thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
if (pageState.detectedBlankPages.length === 0) {
|
||||
showAlert('Info', 'No blank pages detected in this PDF.');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preview panel
|
||||
updatePreviewPanel();
|
||||
document.getElementById('preview-panel')?.classList.remove('hidden');
|
||||
hideLoader();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Could not detect blank pages.');
|
||||
hideLoader();
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const page = await pdfDoc.getPage(i);
|
||||
if (await isPageBlank(page, maxNonWhitePercent)) {
|
||||
pageState.detectedBlankPages.push(i - 1); // 0-indexed
|
||||
const thumbnail = await generateThumbnail(page);
|
||||
pageState.pageThumbnails.set(i - 1, thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
if (pageState.detectedBlankPages.length === 0) {
|
||||
showAlert('Info', 'No blank pages detected in this PDF.');
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preview panel
|
||||
updatePreviewPanel();
|
||||
document.getElementById('preview-panel')?.classList.remove('hidden');
|
||||
|
||||
const previewContainer = document.getElementById('blank-pages-preview');
|
||||
if (previewContainer) initPagePreview(previewContainer, pdfDoc);
|
||||
|
||||
hideLoader();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Could not detect blank pages.');
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePreviewPanel() {
|
||||
const previewInfo = document.getElementById('preview-info');
|
||||
const previewContainer = document.getElementById('blank-pages-preview');
|
||||
const previewInfo = document.getElementById('preview-info');
|
||||
const previewContainer = document.getElementById('blank-pages-preview');
|
||||
|
||||
if (!previewInfo || !previewContainer) return;
|
||||
if (!previewInfo || !previewContainer) return;
|
||||
|
||||
previewInfo.textContent = `Found ${pageState.detectedBlankPages.length} blank page(s). Click on a page to deselect it.`;
|
||||
previewContainer.innerHTML = '';
|
||||
previewInfo.textContent = `Found ${pageState.detectedBlankPages.length} blank page(s). Click on a page to deselect it.`;
|
||||
previewContainer.innerHTML = '';
|
||||
|
||||
pageState.detectedBlankPages.forEach((pageIndex) => {
|
||||
const thumbnail = pageState.pageThumbnails.get(pageIndex) || '';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'relative cursor-pointer group';
|
||||
div.dataset.pageIndex = String(pageIndex);
|
||||
div.dataset.selected = 'true';
|
||||
pageState.detectedBlankPages.forEach((pageIndex) => {
|
||||
const thumbnail = pageState.pageThumbnails.get(pageIndex) || '';
|
||||
const div = document.createElement('div');
|
||||
div.className =
|
||||
'relative cursor-pointer flex flex-col items-center gap-1 p-2 border-2 border-red-500 rounded-lg bg-gray-700 transition-colors group';
|
||||
div.dataset.pageIndex = String(pageIndex);
|
||||
div.dataset.selected = 'true';
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="relative border-2 border-red-500 rounded-lg overflow-hidden transition-all">
|
||||
<img src="${thumbnail}" alt="Page ${pageIndex + 1}" class="w-full h-auto">
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-black/70 text-white text-xs text-center py-1">
|
||||
Page ${pageIndex + 1}
|
||||
div.innerHTML = `
|
||||
<div class="relative">
|
||||
<img src="${thumbnail}" alt="Page ${pageIndex + 1}" class="rounded-md shadow-md max-w-full h-auto">
|
||||
<div class="absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none">
|
||||
${pageIndex + 1}
|
||||
</div>
|
||||
<div class="absolute top-1 right-1 bg-red-500 rounded-full w-5 h-5 flex items-center justify-center check-mark">
|
||||
<div class="absolute top-1 right-1 bg-red-500 rounded-full w-5 h-5 flex items-center justify-center check-mark z-10">
|
||||
<i data-lucide="check" class="w-3 h-3 text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
div.addEventListener('click', () => togglePageSelection(div, pageIndex));
|
||||
previewContainer.appendChild(div);
|
||||
});
|
||||
div.addEventListener('click', () => togglePageSelection(div, pageIndex));
|
||||
previewContainer.appendChild(div);
|
||||
});
|
||||
|
||||
createIcons({ icons });
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
function togglePageSelection(div: HTMLElement, pageIndex: number) {
|
||||
const isSelected = div.dataset.selected === 'true';
|
||||
const border = div.querySelector('.border-2') as HTMLElement;
|
||||
const checkMark = div.querySelector('.check-mark') as HTMLElement;
|
||||
const isSelected = div.dataset.selected === 'true';
|
||||
const checkMark = div.querySelector('.check-mark') as HTMLElement;
|
||||
|
||||
if (isSelected) {
|
||||
div.dataset.selected = 'false';
|
||||
border?.classList.remove('border-red-500');
|
||||
border?.classList.add('border-gray-500', 'opacity-50');
|
||||
checkMark?.classList.add('hidden');
|
||||
} else {
|
||||
div.dataset.selected = 'true';
|
||||
border?.classList.add('border-red-500');
|
||||
border?.classList.remove('border-gray-500', 'opacity-50');
|
||||
checkMark?.classList.remove('hidden');
|
||||
}
|
||||
if (isSelected) {
|
||||
div.dataset.selected = 'false';
|
||||
div.classList.remove('border-red-500');
|
||||
div.classList.add('border-gray-600', 'opacity-50');
|
||||
checkMark?.classList.add('hidden');
|
||||
} else {
|
||||
div.dataset.selected = 'true';
|
||||
div.classList.add('border-red-500');
|
||||
div.classList.remove('border-gray-600', 'opacity-50');
|
||||
checkMark?.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function processRemoveBlankPages() {
|
||||
if (!pageState.pdfDoc || !pageState.file) return showAlert('Error', 'Please upload a PDF first.');
|
||||
if (!pageState.pdfDoc || !pageState.file)
|
||||
return showAlert('Error', 'Please upload a PDF first.');
|
||||
|
||||
// Get selected pages to remove
|
||||
const previewContainer = document.getElementById('blank-pages-preview');
|
||||
const selectedPages: number[] = [];
|
||||
previewContainer?.querySelectorAll('[data-selected="true"]').forEach(el => {
|
||||
const pageIndex = parseInt((el as HTMLElement).dataset.pageIndex || '-1');
|
||||
if (pageIndex >= 0) selectedPages.push(pageIndex);
|
||||
});
|
||||
// Get selected pages to remove
|
||||
const previewContainer = document.getElementById('blank-pages-preview');
|
||||
const selectedPages: number[] = [];
|
||||
previewContainer?.querySelectorAll('[data-selected="true"]').forEach((el) => {
|
||||
const pageIndex = parseInt((el as HTMLElement).dataset.pageIndex || '-1');
|
||||
if (pageIndex >= 0) selectedPages.push(pageIndex);
|
||||
});
|
||||
|
||||
if (selectedPages.length === 0) {
|
||||
showAlert('Info', 'No pages selected for removal.');
|
||||
return;
|
||||
if (selectedPages.length === 0) {
|
||||
showAlert('Info', 'No pages selected for removal.');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoader(`Removing ${selectedPages.length} blank page(s)...`);
|
||||
try {
|
||||
const newPdf = await PDFDocument.create();
|
||||
const pages = pageState.pdfDoc.getPages();
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (!selectedPages.includes(i)) {
|
||||
const [copiedPage] = await newPdf.copyPages(pageState.pdfDoc, [i]);
|
||||
newPdf.addPage(copiedPage);
|
||||
}
|
||||
}
|
||||
|
||||
showLoader(`Removing ${selectedPages.length} blank page(s)...`);
|
||||
try {
|
||||
const newPdf = await PDFDocument.create();
|
||||
const pages = pageState.pdfDoc.getPages();
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (!selectedPages.includes(i)) {
|
||||
const [copiedPage] = await newPdf.copyPages(pageState.pdfDoc, [i]);
|
||||
newPdf.addPage(copiedPage);
|
||||
}
|
||||
}
|
||||
|
||||
const newPdfBytes = await newPdf.save();
|
||||
downloadFile(new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), 'blank-pages-removed.pdf');
|
||||
showAlert('Success', `Removed ${selectedPages.length} blank page(s) successfully!`, 'success', resetState);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Could not remove blank pages.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
const newPdfBytes = await newPdf.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'blank-pages-removed.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
`Removed ${selectedPages.length} blank page(s) successfully!`,
|
||||
'success',
|
||||
resetState
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'Could not remove blank pages.');
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const detectBtn = document.getElementById('detect-btn');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const sensitivitySlider = document.getElementById('sensitivity-slider') as HTMLInputElement;
|
||||
const sensitivityValue = document.getElementById('sensitivity-value');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const detectBtn = document.getElementById('detect-btn');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const sensitivitySlider = document.getElementById(
|
||||
'sensitivity-slider'
|
||||
) as HTMLInputElement;
|
||||
const sensitivityValue = document.getElementById('sensitivity-value');
|
||||
|
||||
sensitivitySlider?.addEventListener('input', (e) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
if (sensitivityValue) sensitivityValue.textContent = value;
|
||||
});
|
||||
sensitivitySlider?.addEventListener('input', (e) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
if (sensitivityValue) sensitivityValue.textContent = value;
|
||||
});
|
||||
|
||||
fileInput?.addEventListener('change', (e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) handleFileUpload(f);
|
||||
});
|
||||
fileInput?.addEventListener('change', (e) => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (f) handleFileUpload(f);
|
||||
});
|
||||
|
||||
dropZone?.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-indigo-500');
|
||||
});
|
||||
dropZone?.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-indigo-500');
|
||||
});
|
||||
|
||||
dropZone?.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
});
|
||||
dropZone?.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
});
|
||||
|
||||
dropZone?.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
const f = e.dataTransfer?.files[0];
|
||||
if (f) handleFileUpload(f);
|
||||
});
|
||||
dropZone?.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-indigo-500');
|
||||
const f = e.dataTransfer?.files[0];
|
||||
if (f) handleFileUpload(f);
|
||||
});
|
||||
|
||||
detectBtn?.addEventListener('click', detectBlankPages);
|
||||
processBtn?.addEventListener('click', processRemoveBlankPages);
|
||||
detectBtn?.addEventListener('click', detectBlankPages);
|
||||
processBtn?.addEventListener('click', processRemoveBlankPages);
|
||||
|
||||
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
||||
window.location.href = '../../index.html';
|
||||
});
|
||||
document.getElementById('back-to-tools')?.addEventListener('click', () => {
|
||||
window.location.href = '../../index.html';
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
renderPagesProgressively,
|
||||
cleanupLazyRendering,
|
||||
} from '../utils/render-utils.js';
|
||||
import { initPagePreview } from '../utils/page-preview.js';
|
||||
import { isCpdfAvailable } from '../utils/cpdf-helper.js';
|
||||
import { showWasmRequiredDialog } from '../utils/wasm-provider.js';
|
||||
import JSZip from 'jszip';
|
||||
@@ -153,18 +154,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
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';
|
||||
'page-thumbnail-wrapper p-2 border-2 border-gray-600 rounded-lg cursor-pointer hover:border-indigo-500 bg-gray-700 transition-colors relative group flex flex-col items-center gap-1';
|
||||
wrapper.dataset.pageIndex = (pageNumber - 1).toString();
|
||||
wrapper.dataset.pageNumber = pageNumber.toString();
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className = 'relative';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'rounded-md w-full h-auto';
|
||||
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.className = 'text-center text-xs mt-1 text-gray-300';
|
||||
p.textContent = `Page ${pageNumber}`;
|
||||
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 z-10 pointer-events-none';
|
||||
pageNumDiv.textContent = pageNumber.toString();
|
||||
|
||||
wrapper.append(img, p);
|
||||
imgContainer.append(img, pageNumDiv);
|
||||
wrapper.appendChild(imgContainer);
|
||||
|
||||
const handleSelection = (e: any) => {
|
||||
e.preventDefault();
|
||||
@@ -174,10 +181,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (isSelected) {
|
||||
wrapper.classList.remove('selected', 'border-indigo-500');
|
||||
wrapper.classList.add('border-transparent');
|
||||
wrapper.classList.add('border-gray-600');
|
||||
} else {
|
||||
wrapper.classList.add('selected', 'border-indigo-500');
|
||||
wrapper.classList.remove('border-transparent');
|
||||
wrapper.classList.remove('border-gray-600');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,6 +210,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
createIcons({ icons });
|
||||
},
|
||||
});
|
||||
|
||||
initPagePreview(container, pdf);
|
||||
} catch (error) {
|
||||
console.error('Error rendering visual selector:', error);
|
||||
showAlert('Error', 'Failed to render page previews.');
|
||||
|
||||
@@ -50,3 +50,4 @@ export * from './bookmark-pdf-type.ts';
|
||||
export * from './scanner-effect-type.ts';
|
||||
export * from './adjust-colors-type.ts';
|
||||
export * from './bates-numbering-type.ts';
|
||||
export * from './page-preview-type.ts';
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
|
||||
export interface PreviewState {
|
||||
modal: HTMLElement | null;
|
||||
pdfjsDoc: PDFDocumentProxy | null;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
isOpen: boolean;
|
||||
container: HTMLElement | null;
|
||||
}
|
||||
+376
-347
@@ -1,177 +1,201 @@
|
||||
import { resetState } from './state.js';
|
||||
import { formatBytes, getPDFDocument } from './utils/helpers.js';
|
||||
import { tesseractLanguages } from './config/tesseract-languages.js';
|
||||
import { renderPagesProgressively, cleanupLazyRendering } from './utils/render-utils.js';
|
||||
import {
|
||||
renderPagesProgressively,
|
||||
cleanupLazyRendering,
|
||||
} from './utils/render-utils.js';
|
||||
import { initPagePreview } from './utils/page-preview.js';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import Sortable from 'sortablejs';
|
||||
import { getRotationState, updateRotationState } from './utils/rotation-state.js';
|
||||
import {
|
||||
getRotationState,
|
||||
updateRotationState,
|
||||
} from './utils/rotation-state.js';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { t } from './i18n/i18n';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
// Centralizing DOM element selection
|
||||
export const dom = {
|
||||
gridView: document.getElementById('grid-view'),
|
||||
toolGrid: document.getElementById('tool-grid'),
|
||||
toolInterface: document.getElementById('tool-interface'),
|
||||
toolContent: document.getElementById('tool-content'),
|
||||
backToGridBtn: document.getElementById('back-to-grid'),
|
||||
loaderModal: document.getElementById('loader-modal'),
|
||||
loaderText: document.getElementById('loader-text'),
|
||||
alertModal: document.getElementById('alert-modal'),
|
||||
alertTitle: document.getElementById('alert-title'),
|
||||
alertMessage: document.getElementById('alert-message'),
|
||||
alertOkBtn: document.getElementById('alert-ok'),
|
||||
heroSection: document.getElementById('hero-section'),
|
||||
featuresSection: document.getElementById('features-section'),
|
||||
toolsHeader: document.getElementById('tools-header'),
|
||||
dividers: document.querySelectorAll('.section-divider'),
|
||||
hideSections: document.querySelectorAll('.hide-section'),
|
||||
shortcutsModal: document.getElementById('shortcuts-modal'),
|
||||
closeShortcutsModalBtn: document.getElementById('close-shortcuts-modal'),
|
||||
shortcutsList: document.getElementById('shortcuts-list'),
|
||||
shortcutSearch: document.getElementById('shortcut-search'),
|
||||
resetShortcutsBtn: document.getElementById('reset-shortcuts-btn'),
|
||||
importShortcutsBtn: document.getElementById('import-shortcuts-btn'),
|
||||
exportShortcutsBtn: document.getElementById('export-shortcuts-btn'),
|
||||
openShortcutsBtn: document.getElementById('open-shortcuts-btn'),
|
||||
warningModal: document.getElementById('warning-modal'),
|
||||
warningTitle: document.getElementById('warning-title'),
|
||||
warningMessage: document.getElementById('warning-message'),
|
||||
warningCancelBtn: document.getElementById('warning-cancel-btn'),
|
||||
warningConfirmBtn: document.getElementById('warning-confirm-btn'),
|
||||
gridView: document.getElementById('grid-view'),
|
||||
toolGrid: document.getElementById('tool-grid'),
|
||||
toolInterface: document.getElementById('tool-interface'),
|
||||
toolContent: document.getElementById('tool-content'),
|
||||
backToGridBtn: document.getElementById('back-to-grid'),
|
||||
loaderModal: document.getElementById('loader-modal'),
|
||||
loaderText: document.getElementById('loader-text'),
|
||||
alertModal: document.getElementById('alert-modal'),
|
||||
alertTitle: document.getElementById('alert-title'),
|
||||
alertMessage: document.getElementById('alert-message'),
|
||||
alertOkBtn: document.getElementById('alert-ok'),
|
||||
heroSection: document.getElementById('hero-section'),
|
||||
featuresSection: document.getElementById('features-section'),
|
||||
toolsHeader: document.getElementById('tools-header'),
|
||||
dividers: document.querySelectorAll('.section-divider'),
|
||||
hideSections: document.querySelectorAll('.hide-section'),
|
||||
shortcutsModal: document.getElementById('shortcuts-modal'),
|
||||
closeShortcutsModalBtn: document.getElementById('close-shortcuts-modal'),
|
||||
shortcutsList: document.getElementById('shortcuts-list'),
|
||||
shortcutSearch: document.getElementById('shortcut-search'),
|
||||
resetShortcutsBtn: document.getElementById('reset-shortcuts-btn'),
|
||||
importShortcutsBtn: document.getElementById('import-shortcuts-btn'),
|
||||
exportShortcutsBtn: document.getElementById('export-shortcuts-btn'),
|
||||
openShortcutsBtn: document.getElementById('open-shortcuts-btn'),
|
||||
warningModal: document.getElementById('warning-modal'),
|
||||
warningTitle: document.getElementById('warning-title'),
|
||||
warningMessage: document.getElementById('warning-message'),
|
||||
warningCancelBtn: document.getElementById('warning-cancel-btn'),
|
||||
warningConfirmBtn: document.getElementById('warning-confirm-btn'),
|
||||
};
|
||||
|
||||
export const showLoader = (text = t('common.loading'), progress?: number) => {
|
||||
if (dom.loaderText) dom.loaderText.textContent = text;
|
||||
if (dom.loaderText) dom.loaderText.textContent = text;
|
||||
|
||||
// Add or update progress bar if progress is provided
|
||||
const loaderModal = dom.loaderModal;
|
||||
if (loaderModal) {
|
||||
let progressBar = loaderModal.querySelector('.loader-progress-bar') as HTMLElement;
|
||||
let progressContainer = loaderModal.querySelector('.loader-progress-container') as HTMLElement;
|
||||
// Add or update progress bar if progress is provided
|
||||
const loaderModal = dom.loaderModal;
|
||||
if (loaderModal) {
|
||||
let progressBar = loaderModal.querySelector(
|
||||
'.loader-progress-bar'
|
||||
) as HTMLElement;
|
||||
let progressContainer = loaderModal.querySelector(
|
||||
'.loader-progress-container'
|
||||
) as HTMLElement;
|
||||
|
||||
if (progress !== undefined && progress >= 0) {
|
||||
// Create progress container if it doesn't exist
|
||||
if (!progressContainer) {
|
||||
progressContainer = document.createElement('div');
|
||||
progressContainer.className = 'loader-progress-container w-64 mt-4';
|
||||
progressContainer.innerHTML = `
|
||||
if (progress !== undefined && progress >= 0) {
|
||||
// Create progress container if it doesn't exist
|
||||
if (!progressContainer) {
|
||||
progressContainer = document.createElement('div');
|
||||
progressContainer.className = 'loader-progress-container w-64 mt-4';
|
||||
progressContainer.innerHTML = `
|
||||
<div class="bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||
<div class="loader-progress-bar bg-indigo-500 h-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<p class="loader-progress-text text-xs text-gray-400 mt-1 text-center">0%</p>
|
||||
`;
|
||||
loaderModal.querySelector('.bg-gray-800')?.appendChild(progressContainer);
|
||||
progressBar = progressContainer.querySelector('.loader-progress-bar') as HTMLElement;
|
||||
}
|
||||
loaderModal
|
||||
.querySelector('.bg-gray-800')
|
||||
?.appendChild(progressContainer);
|
||||
progressBar = progressContainer.querySelector(
|
||||
'.loader-progress-bar'
|
||||
) as HTMLElement;
|
||||
}
|
||||
|
||||
// Update progress
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${progress}%`;
|
||||
}
|
||||
const progressText = progressContainer.querySelector('.loader-progress-text');
|
||||
if (progressText) {
|
||||
progressText.textContent = `${Math.round(progress)}%`;
|
||||
}
|
||||
progressContainer.classList.remove('hidden');
|
||||
} else {
|
||||
// Hide progress bar if no progress provided
|
||||
if (progressContainer) {
|
||||
progressContainer.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
loaderModal.classList.remove('hidden');
|
||||
// Update progress
|
||||
if (progressBar) {
|
||||
progressBar.style.width = `${progress}%`;
|
||||
}
|
||||
const progressText = progressContainer.querySelector(
|
||||
'.loader-progress-text'
|
||||
);
|
||||
if (progressText) {
|
||||
progressText.textContent = `${Math.round(progress)}%`;
|
||||
}
|
||||
progressContainer.classList.remove('hidden');
|
||||
} else {
|
||||
// Hide progress bar if no progress provided
|
||||
if (progressContainer) {
|
||||
progressContainer.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
loaderModal.classList.remove('hidden');
|
||||
}
|
||||
};
|
||||
|
||||
export const hideLoader = () => {
|
||||
if (dom.loaderModal) dom.loaderModal.classList.add('hidden');
|
||||
if (dom.loaderModal) dom.loaderModal.classList.add('hidden');
|
||||
};
|
||||
|
||||
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');
|
||||
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;
|
||||
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();
|
||||
});
|
||||
}
|
||||
newOkBtn.addEventListener('click', () => {
|
||||
hideAlert();
|
||||
if (callback) callback();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const hideAlert = () => {
|
||||
if (dom.alertModal) dom.alertModal.classList.add('hidden');
|
||||
if (dom.alertModal) dom.alertModal.classList.add('hidden');
|
||||
};
|
||||
|
||||
export const switchView = (view: any) => {
|
||||
if (view === 'grid') {
|
||||
dom.gridView.classList.remove('hidden');
|
||||
dom.toolInterface.classList.add('hidden');
|
||||
// show hero and features and header
|
||||
dom.heroSection.classList.remove('hidden');
|
||||
dom.featuresSection.classList.remove('hidden');
|
||||
dom.toolsHeader.classList.remove('hidden');
|
||||
// show dividers
|
||||
dom.dividers.forEach((divider) => {
|
||||
divider.classList.remove('hidden');
|
||||
});
|
||||
// show hideSections
|
||||
dom.hideSections.forEach((section) => {
|
||||
section.classList.remove('hidden');
|
||||
});
|
||||
if (view === 'grid') {
|
||||
dom.gridView.classList.remove('hidden');
|
||||
dom.toolInterface.classList.add('hidden');
|
||||
// show hero and features and header
|
||||
dom.heroSection.classList.remove('hidden');
|
||||
dom.featuresSection.classList.remove('hidden');
|
||||
dom.toolsHeader.classList.remove('hidden');
|
||||
// show dividers
|
||||
dom.dividers.forEach((divider) => {
|
||||
divider.classList.remove('hidden');
|
||||
});
|
||||
// show hideSections
|
||||
dom.hideSections.forEach((section) => {
|
||||
section.classList.remove('hidden');
|
||||
});
|
||||
|
||||
resetState();
|
||||
} else {
|
||||
dom.gridView.classList.add('hidden');
|
||||
dom.toolInterface.classList.remove('hidden');
|
||||
dom.featuresSection.classList.add('hidden');
|
||||
dom.heroSection.classList.add('hidden');
|
||||
dom.toolsHeader.classList.add('hidden');
|
||||
dom.dividers.forEach((divider) => {
|
||||
divider.classList.add('hidden');
|
||||
});
|
||||
dom.hideSections.forEach((section) => {
|
||||
section.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
resetState();
|
||||
} else {
|
||||
dom.gridView.classList.add('hidden');
|
||||
dom.toolInterface.classList.remove('hidden');
|
||||
dom.featuresSection.classList.add('hidden');
|
||||
dom.heroSection.classList.add('hidden');
|
||||
dom.toolsHeader.classList.add('hidden');
|
||||
dom.dividers.forEach((divider) => {
|
||||
divider.classList.add('hidden');
|
||||
});
|
||||
dom.hideSections.forEach((section) => {
|
||||
section.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const thumbnailState = {
|
||||
sortableInstances: {},
|
||||
sortableInstances: {},
|
||||
};
|
||||
|
||||
function initializeOrganizeSortable(containerId: any) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
if (thumbnailState.sortableInstances[containerId]) {
|
||||
thumbnailState.sortableInstances[containerId].destroy();
|
||||
}
|
||||
if (thumbnailState.sortableInstances[containerId]) {
|
||||
thumbnailState.sortableInstances[containerId].destroy();
|
||||
}
|
||||
|
||||
thumbnailState.sortableInstances[containerId] = Sortable.create(container, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
filter: '.delete-page-btn',
|
||||
preventOnFilter: true,
|
||||
onStart: function (evt: any) {
|
||||
evt.item.style.opacity = '0.5';
|
||||
},
|
||||
onEnd: function (evt: any) {
|
||||
evt.item.style.opacity = '1';
|
||||
},
|
||||
});
|
||||
thumbnailState.sortableInstances[containerId] = Sortable.create(container, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
filter: '.delete-page-btn',
|
||||
preventOnFilter: true,
|
||||
onStart: function (evt: any) {
|
||||
evt.item.style.opacity = '0.5';
|
||||
},
|
||||
onEnd: function (evt: any) {
|
||||
evt.item.style.opacity = '1';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,243 +204,247 @@ function initializeOrganizeSortable(containerId: any) {
|
||||
* @param {object} pdfDoc The loaded pdf-lib document instance.
|
||||
*/
|
||||
export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
const containerId = toolId === 'organize' ? 'page-organizer' : toolId === 'delete-pages' ? 'delete-pages-preview' : 'page-rotator';
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const containerId =
|
||||
toolId === 'organize'
|
||||
? 'page-organizer'
|
||||
: toolId === 'delete-pages'
|
||||
? 'delete-pages-preview'
|
||||
: 'page-rotator';
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.innerHTML = '';
|
||||
|
||||
// Cleanup any previous lazy loading observers
|
||||
cleanupLazyRendering();
|
||||
// Cleanup any previous lazy loading observers
|
||||
cleanupLazyRendering();
|
||||
|
||||
const currentRenderId = Date.now();
|
||||
container.dataset.renderId = currentRenderId.toString();
|
||||
const currentRenderId = Date.now();
|
||||
container.dataset.renderId = currentRenderId.toString();
|
||||
|
||||
showLoader(t('multiTool.renderingTitle'));
|
||||
showLoader(t('multiTool.renderingTitle'));
|
||||
|
||||
const pdfData = await pdfDoc.save();
|
||||
const pdf = await getPDFDocument({ data: pdfData }).promise;
|
||||
const pdfData = await 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');
|
||||
// @ts-expect-error TS(2322) FIXME: Type 'number' is not assignable to type 'string'.
|
||||
wrapper.dataset.pageIndex = pageNumber - 1;
|
||||
// Function to create wrapper element for each page
|
||||
const createWrapper = (canvas: HTMLCanvasElement, pageNumber: number) => {
|
||||
const wrapper = document.createElement('div');
|
||||
// @ts-expect-error TS(2322) FIXME: Type 'number' is not assignable to type 'string'.
|
||||
wrapper.dataset.pageIndex = pageNumber - 1;
|
||||
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className =
|
||||
'w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600';
|
||||
const imgContainer = document.createElement('div');
|
||||
imgContainer.className = 'relative';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'max-w-full max-h-full object-contain';
|
||||
const img = document.createElement('img');
|
||||
img.src = canvas.toDataURL();
|
||||
img.className = 'rounded-md shadow-md max-w-full h-auto';
|
||||
|
||||
imgContainer.appendChild(img);
|
||||
imgContainer.appendChild(img);
|
||||
|
||||
if (toolId === 'organize') {
|
||||
wrapper.className = 'page-thumbnail relative group';
|
||||
wrapper.appendChild(imgContainer);
|
||||
const pageNumSpan = document.createElement('div');
|
||||
pageNumSpan.className =
|
||||
'absolute top-1 left-1 bg-indigo-600 text-white text-xs px-2 py-1 rounded-md font-semibold shadow-lg z-10 pointer-events-none';
|
||||
pageNumSpan.textContent = pageNumber.toString();
|
||||
|
||||
const pageNumSpan = document.createElement('span');
|
||||
pageNumSpan.className =
|
||||
'absolute top-1 left-1 bg-gray-900 bg-opacity-75 text-white text-xs rounded-full px-2 py-1';
|
||||
pageNumSpan.textContent = pageNumber.toString();
|
||||
if (toolId === 'organize') {
|
||||
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 group';
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className =
|
||||
'delete-page-btn absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center';
|
||||
deleteBtn.innerHTML = '×';
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
(e.currentTarget as HTMLElement).parentElement.remove();
|
||||
imgContainer.appendChild(pageNumSpan);
|
||||
wrapper.appendChild(imgContainer);
|
||||
|
||||
// Renumber remaining pages
|
||||
const pages = container.querySelectorAll('.page-thumbnail');
|
||||
pages.forEach((page, index) => {
|
||||
const numSpan = page.querySelector('span');
|
||||
if (numSpan) {
|
||||
numSpan.textContent = (index + 1).toString();
|
||||
}
|
||||
});
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className =
|
||||
'delete-page-btn absolute top-1 right-1 bg-red-600 hover:bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center z-10';
|
||||
deleteBtn.innerHTML = '×';
|
||||
deleteBtn.addEventListener('click', (e) => {
|
||||
(e.currentTarget as HTMLElement).parentElement.remove();
|
||||
|
||||
initializeOrganizeSortable(containerId);
|
||||
});
|
||||
// Renumber remaining pages
|
||||
const pages = container.querySelectorAll('.page-thumbnail');
|
||||
pages.forEach((page, index) => {
|
||||
const numSpan = page.querySelector('.bg-indigo-600');
|
||||
if (numSpan) {
|
||||
numSpan.textContent = (index + 1).toString();
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.append(pageNumSpan, deleteBtn);
|
||||
} else if (toolId === 'rotate') {
|
||||
wrapper.className = 'page-rotator-item flex flex-col items-center gap-2 relative group';
|
||||
initializeOrganizeSortable(containerId);
|
||||
});
|
||||
|
||||
// Read rotation from state (handles "Rotate All" on lazy-loaded pages)
|
||||
const rotationStateArray = getRotationState();
|
||||
const pageIndex = pageNumber - 1;
|
||||
const initialRotation = rotationStateArray[pageIndex] || 0;
|
||||
wrapper.appendChild(deleteBtn);
|
||||
} else if (toolId === 'rotate') {
|
||||
wrapper.className =
|
||||
'page-rotator-item flex flex-col items-center gap-2 p-2 border-2 border-gray-600 hover:border-indigo-500 rounded-lg bg-gray-700 transition-colors relative group';
|
||||
|
||||
wrapper.dataset.rotation = initialRotation.toString();
|
||||
img.classList.add('transition-transform', 'duration-300');
|
||||
// Read rotation from state (handles "Rotate All" on lazy-loaded pages)
|
||||
const rotationStateArray = getRotationState();
|
||||
const pageIndex = pageNumber - 1;
|
||||
const initialRotation = rotationStateArray[pageIndex] || 0;
|
||||
|
||||
// Apply initial rotation if any
|
||||
if (initialRotation !== 0) {
|
||||
img.style.transform = `rotate(${initialRotation}deg)`;
|
||||
}
|
||||
wrapper.dataset.rotation = initialRotation.toString();
|
||||
img.classList.add('transition-transform', 'duration-300');
|
||||
|
||||
wrapper.appendChild(imgContainer);
|
||||
// Apply initial rotation if any
|
||||
if (initialRotation !== 0) {
|
||||
img.style.transform = `rotate(${initialRotation}deg)`;
|
||||
}
|
||||
|
||||
// Page Number Overlay (Top Left)
|
||||
const pageNumSpan = document.createElement('span');
|
||||
pageNumSpan.className =
|
||||
'absolute top-2 left-2 bg-gray-900 bg-opacity-75 text-white text-xs font-medium rounded-md px-2 py-1 shadow-sm z-10 pointer-events-none';
|
||||
pageNumSpan.textContent = pageNumber.toString();
|
||||
wrapper.appendChild(pageNumSpan);
|
||||
imgContainer.appendChild(pageNumSpan);
|
||||
wrapper.appendChild(imgContainer);
|
||||
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className = 'flex flex-col lg:flex-row items-center justify-center w-full gap-2 px-1';
|
||||
const controlsDiv = document.createElement('div');
|
||||
controlsDiv.className =
|
||||
'flex flex-col lg:flex-row items-center justify-center w-full gap-2 px-1';
|
||||
|
||||
// Custom Stepper Component
|
||||
const stepperContainer = document.createElement('div');
|
||||
stepperContainer.className = 'flex items-center border border-gray-600 rounded-md bg-gray-800 overflow-hidden w-24 h-8';
|
||||
// Custom Stepper Component
|
||||
const stepperContainer = document.createElement('div');
|
||||
stepperContainer.className =
|
||||
'flex items-center border border-gray-600 rounded-md bg-gray-800 overflow-hidden w-24 h-8';
|
||||
|
||||
const decrementBtn = document.createElement('button');
|
||||
decrementBtn.className = 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-r border-gray-600 transition-colors flex items-center justify-center';
|
||||
decrementBtn.innerHTML = '<i data-lucide="minus" class="w-3 h-3"></i>';
|
||||
const decrementBtn = document.createElement('button');
|
||||
decrementBtn.className =
|
||||
'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-r border-gray-600 transition-colors flex items-center justify-center';
|
||||
decrementBtn.innerHTML = '<i data-lucide="minus" class="w-3 h-3"></i>';
|
||||
|
||||
const angleInput = document.createElement('input');
|
||||
angleInput.type = 'number';
|
||||
angleInput.className = 'no-spinner w-full h-full bg-transparent text-white text-xs text-center focus:outline-none appearance-none m-0 p-0 border-none';
|
||||
angleInput.value = initialRotation.toString();
|
||||
angleInput.placeholder = "0";
|
||||
const angleInput = document.createElement('input');
|
||||
angleInput.type = 'number';
|
||||
angleInput.className =
|
||||
'no-spinner w-full h-full bg-transparent text-white text-xs text-center focus:outline-none appearance-none m-0 p-0 border-none';
|
||||
angleInput.value = initialRotation.toString();
|
||||
angleInput.placeholder = '0';
|
||||
|
||||
const incrementBtn = document.createElement('button');
|
||||
incrementBtn.className = 'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-l border-gray-600 transition-colors flex items-center justify-center';
|
||||
incrementBtn.innerHTML = '<i data-lucide="plus" class="w-3 h-3"></i>';
|
||||
const incrementBtn = document.createElement('button');
|
||||
incrementBtn.className =
|
||||
'px-2 h-full text-gray-400 hover:text-white hover:bg-gray-700 border-l border-gray-600 transition-colors flex items-center justify-center';
|
||||
incrementBtn.innerHTML = '<i data-lucide="plus" class="w-3 h-3"></i>';
|
||||
|
||||
// Helper to update rotation
|
||||
const updateRotation = (newRotation: number) => {
|
||||
const card = wrapper; // Closure capture
|
||||
const imgEl = card.querySelector('img');
|
||||
const pageIndex = pageNumber - 1;
|
||||
// Helper to update rotation
|
||||
const updateRotation = (newRotation: number) => {
|
||||
const card = wrapper; // Closure capture
|
||||
const imgEl = card.querySelector('img');
|
||||
const pageIndex = pageNumber - 1;
|
||||
|
||||
// Update UI
|
||||
angleInput.value = newRotation.toString();
|
||||
card.dataset.rotation = newRotation.toString();
|
||||
imgEl.style.transform = `rotate(${newRotation}deg)`;
|
||||
// Update UI
|
||||
angleInput.value = newRotation.toString();
|
||||
card.dataset.rotation = newRotation.toString();
|
||||
imgEl.style.transform = `rotate(${newRotation}deg)`;
|
||||
|
||||
// Update State
|
||||
updateRotationState(pageIndex, newRotation);
|
||||
};
|
||||
// Update State
|
||||
updateRotationState(pageIndex, newRotation);
|
||||
};
|
||||
|
||||
// Event Listeners
|
||||
decrementBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const current = parseInt(angleInput.value) || 0;
|
||||
updateRotation(current - 1);
|
||||
});
|
||||
// Event Listeners
|
||||
decrementBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const current = parseInt(angleInput.value) || 0;
|
||||
updateRotation(current - 1);
|
||||
});
|
||||
|
||||
incrementBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const current = parseInt(angleInput.value) || 0;
|
||||
updateRotation(current + 1);
|
||||
});
|
||||
incrementBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const current = parseInt(angleInput.value) || 0;
|
||||
updateRotation(current + 1);
|
||||
});
|
||||
|
||||
angleInput.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
const val = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
updateRotation(val);
|
||||
});
|
||||
angleInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
angleInput.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
const val = parseInt((e.target as HTMLInputElement).value) || 0;
|
||||
updateRotation(val);
|
||||
});
|
||||
angleInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
stepperContainer.append(decrementBtn, angleInput, incrementBtn);
|
||||
stepperContainer.append(decrementBtn, angleInput, incrementBtn);
|
||||
|
||||
const rotateBtn = document.createElement('button');
|
||||
rotateBtn.className = 'rotate-btn btn bg-gray-700 hover:bg-gray-600 p-1.5 rounded-md text-gray-200 transition-colors flex-shrink-0';
|
||||
rotateBtn.title = 'Rotate +90°';
|
||||
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4"></i>';
|
||||
rotateBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const current = parseInt(angleInput.value) || 0;
|
||||
updateRotation(current + 90);
|
||||
});
|
||||
const rotateBtn = document.createElement('button');
|
||||
rotateBtn.className =
|
||||
'rotate-btn btn bg-gray-700 hover:bg-gray-600 p-1.5 rounded-md text-gray-200 transition-colors flex-shrink-0';
|
||||
rotateBtn.title = 'Rotate +90°';
|
||||
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4"></i>';
|
||||
rotateBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const current = parseInt(angleInput.value) || 0;
|
||||
updateRotation(current + 90);
|
||||
});
|
||||
|
||||
controlsDiv.append(stepperContainer, rotateBtn);
|
||||
wrapper.appendChild(controlsDiv);
|
||||
} else if (toolId === 'delete-pages') {
|
||||
wrapper.className = 'page-thumbnail relative group cursor-pointer transition-all duration-200';
|
||||
wrapper.dataset.pageNumber = pageNumber.toString();
|
||||
controlsDiv.append(stepperContainer, rotateBtn);
|
||||
wrapper.appendChild(controlsDiv);
|
||||
} else if (toolId === 'delete-pages') {
|
||||
wrapper.className =
|
||||
'page-thumbnail relative cursor-pointer 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 group';
|
||||
wrapper.dataset.pageNumber = pageNumber.toString();
|
||||
|
||||
const innerContainer = document.createElement('div');
|
||||
innerContainer.className = 'relative w-full h-36 bg-gray-900 rounded-lg flex items-center justify-center overflow-hidden border-2 border-gray-600 transition-colors duration-200';
|
||||
innerContainer.appendChild(img);
|
||||
wrapper.appendChild(innerContainer);
|
||||
imgContainer.appendChild(pageNumSpan);
|
||||
wrapper.appendChild(imgContainer);
|
||||
|
||||
const pageNumSpan = document.createElement('span');
|
||||
pageNumSpan.className =
|
||||
'absolute top-2 left-2 bg-gray-900 bg-opacity-75 text-white text-xs font-medium rounded-md px-2 py-1 shadow-sm z-10 pointer-events-none';
|
||||
pageNumSpan.textContent = pageNumber.toString();
|
||||
wrapper.appendChild(pageNumSpan);
|
||||
wrapper.addEventListener('click', () => {
|
||||
const input = document.getElementById(
|
||||
'pages-to-delete'
|
||||
) as HTMLInputElement;
|
||||
if (!input) return;
|
||||
|
||||
wrapper.addEventListener('click', () => {
|
||||
const input = document.getElementById('pages-to-delete') as HTMLInputElement;
|
||||
if (!input) return;
|
||||
const currentVal = input.value;
|
||||
let pages = currentVal
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s);
|
||||
const pageStr = pageNumber.toString();
|
||||
|
||||
const currentVal = input.value;
|
||||
let pages = currentVal.split(',').map(s => s.trim()).filter(s => s);
|
||||
const pageStr = pageNumber.toString();
|
||||
|
||||
if (pages.includes(pageStr)) {
|
||||
pages = pages.filter(p => p !== pageStr);
|
||||
} else {
|
||||
pages.push(pageStr);
|
||||
}
|
||||
|
||||
pages.sort((a, b) => {
|
||||
const numA = parseInt(a.split('-')[0]);
|
||||
const numB = parseInt(b.split('-')[0]);
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
input.value = pages.join(', ');
|
||||
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
if (pages.includes(pageStr)) {
|
||||
pages = pages.filter((p) => p !== pageStr);
|
||||
} else {
|
||||
pages.push(pageStr);
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
pages.sort((a, b) => {
|
||||
const numA = parseInt(a.split('-')[0]);
|
||||
const numB = parseInt(b.split('-')[0]);
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
try {
|
||||
// Render pages progressively with lazy loading
|
||||
await renderPagesProgressively(
|
||||
pdf,
|
||||
container,
|
||||
createWrapper,
|
||||
{
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '300px',
|
||||
onProgress: (current, total) => {
|
||||
showLoader(`Rendering page previews: ${current}/${total}`);
|
||||
},
|
||||
onBatchComplete: () => {
|
||||
createIcons({ icons });
|
||||
},
|
||||
shouldCancel: () => {
|
||||
return container.dataset.renderId !== currentRenderId.toString();
|
||||
}
|
||||
}
|
||||
);
|
||||
input.value = pages.join(', ');
|
||||
|
||||
if (toolId === 'organize') {
|
||||
initializeOrganizeSortable(containerId);
|
||||
} else if (toolId === 'delete-pages') {
|
||||
// No sortable needed for delete pages
|
||||
}
|
||||
|
||||
// Reinitialize lucide icons for dynamically added elements
|
||||
createIcons({ icons });
|
||||
} catch (error) {
|
||||
console.error('Error rendering page thumbnails:', error);
|
||||
showAlert(t('multiTool.error'), t('multiTool.errorRendering'));
|
||||
} finally {
|
||||
hideLoader();
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
try {
|
||||
// Render pages progressively with lazy loading
|
||||
await renderPagesProgressively(pdf, container, createWrapper, {
|
||||
batchSize: 8,
|
||||
useLazyLoading: true,
|
||||
lazyLoadMargin: '300px',
|
||||
onProgress: (current, total) => {
|
||||
showLoader(`Rendering page previews: ${current}/${total}`);
|
||||
},
|
||||
onBatchComplete: () => {
|
||||
createIcons({ icons });
|
||||
},
|
||||
shouldCancel: () => {
|
||||
return container.dataset.renderId !== currentRenderId.toString();
|
||||
},
|
||||
});
|
||||
|
||||
if (toolId === 'organize') {
|
||||
initializeOrganizeSortable(containerId);
|
||||
} else if (toolId === 'delete-pages') {
|
||||
// No sortable needed for delete pages
|
||||
}
|
||||
|
||||
// Reinitialize lucide icons for dynamically added elements
|
||||
createIcons({ icons });
|
||||
|
||||
// Attach Quick Look page preview
|
||||
initPagePreview(container, pdf);
|
||||
} catch (error) {
|
||||
console.error('Error rendering page thumbnails:', error);
|
||||
showAlert(t('multiTool.error'), t('multiTool.errorRendering'));
|
||||
} finally {
|
||||
hideLoader();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -425,36 +453,36 @@ export const renderPageThumbnails = async (toolId: any, pdfDoc: any) => {
|
||||
* @param {File[]} files The array of file objects.
|
||||
*/
|
||||
export const renderFileDisplay = (container: any, files: any) => {
|
||||
container.textContent = '';
|
||||
if (files.length > 0) {
|
||||
files.forEach((file: any) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
container.textContent = '';
|
||||
if (files.length > 0) {
|
||||
files.forEach((file: any) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200';
|
||||
nameSpan.textContent = file.name;
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200';
|
||||
nameSpan.textContent = file.name;
|
||||
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400';
|
||||
sizeSpan.textContent = formatBytes(file.size);
|
||||
const sizeSpan = document.createElement('span');
|
||||
sizeSpan.className = 'flex-shrink-0 ml-4 text-gray-400';
|
||||
sizeSpan.textContent = formatBytes(file.size);
|
||||
|
||||
fileDiv.append(nameSpan, sizeSpan);
|
||||
container.appendChild(fileDiv);
|
||||
});
|
||||
}
|
||||
fileDiv.append(nameSpan, sizeSpan);
|
||||
container.appendChild(fileDiv);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const createFileInputHTML = (options = {}) => {
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'multiple' does not exist on type '{}'.
|
||||
const multiple = options.multiple ? 'multiple' : '';
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'accept' does not exist on type '{}'.
|
||||
const acceptedFiles = options.accept || 'application/pdf';
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'showControls' does not exist on type '{}... Remove this comment to see the full error message
|
||||
const showControls = options.showControls || false; // NEW: Add this parameter
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'multiple' does not exist on type '{}'.
|
||||
const multiple = options.multiple ? 'multiple' : '';
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'accept' does not exist on type '{}'.
|
||||
const acceptedFiles = options.accept || 'application/pdf';
|
||||
// @ts-expect-error TS(2339) FIXME: Property 'showControls' does not exist on type '{}... Remove this comment to see the full error message
|
||||
const showControls = options.showControls || false; // NEW: Add this parameter
|
||||
|
||||
return `
|
||||
return `
|
||||
<div id="drop-zone" 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 transition-colors duration-300">
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<i data-lucide="upload-cloud" class="w-10 h-10 mb-3 text-gray-400"></i>
|
||||
@@ -465,7 +493,8 @@ const createFileInputHTML = (options = {}) => {
|
||||
<input id="file-input" type="file" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer" ${multiple} accept="${acceptedFiles}">
|
||||
</div>
|
||||
|
||||
${showControls
|
||||
${
|
||||
showControls
|
||||
? `
|
||||
<!-- NEW: Add control buttons for multi-file uploads -->
|
||||
<div id="file-controls" class="hidden mt-4 flex gap-3">
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import { PreviewState } from '@/types';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
const state: PreviewState = {
|
||||
modal: null,
|
||||
pdfjsDoc: null,
|
||||
currentPage: 1,
|
||||
totalPages: 0,
|
||||
isOpen: false,
|
||||
container: null,
|
||||
};
|
||||
|
||||
function getOrCreateModal(): HTMLElement {
|
||||
if (state.modal) return state.modal;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'page-preview-modal';
|
||||
modal.className =
|
||||
'fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-200';
|
||||
modal.innerHTML = `
|
||||
<button id="preview-close" class="absolute top-4 right-4 text-white/70 hover:text-white z-10 transition-colors" title="Close (Esc)">
|
||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
<button id="preview-prev" class="absolute left-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors p-2" title="Previous page">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<button id="preview-next" class="absolute right-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white transition-colors p-2" title="Next page">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
<div id="preview-canvas-container" class="flex items-center justify-center max-w-[90vw] max-h-[85vh]">
|
||||
<div id="preview-loading" class="text-white/60 text-sm">Loading...</div>
|
||||
</div>
|
||||
<div id="preview-page-info" class="absolute bottom-6 left-1/2 -translate-x-1/2 bg-gray-900/80 text-white text-sm px-4 py-2 rounded-full backdrop-blur-sm"></div>
|
||||
`;
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) hidePreview();
|
||||
});
|
||||
modal.querySelector('#preview-close')!.addEventListener('click', hidePreview);
|
||||
modal
|
||||
.querySelector('#preview-prev')!
|
||||
.addEventListener('click', () => navigatePage(-1));
|
||||
modal
|
||||
.querySelector('#preview-next')!
|
||||
.addEventListener('click', () => navigatePage(1));
|
||||
|
||||
document.body.appendChild(modal);
|
||||
state.modal = modal;
|
||||
return modal;
|
||||
}
|
||||
|
||||
async function renderPreviewPage(pageNumber: number): Promise<void> {
|
||||
if (!state.pdfjsDoc) return;
|
||||
|
||||
const modal = getOrCreateModal();
|
||||
const container = modal.querySelector(
|
||||
'#preview-canvas-container'
|
||||
) as HTMLElement;
|
||||
const pageInfo = modal.querySelector('#preview-page-info') as HTMLElement;
|
||||
const prevBtn = modal.querySelector('#preview-prev') as HTMLElement;
|
||||
const nextBtn = modal.querySelector('#preview-next') as HTMLElement;
|
||||
|
||||
container.innerHTML = '<div class="text-white/60 text-sm">Loading...</div>';
|
||||
|
||||
pageInfo.textContent = `Page ${pageNumber} of ${state.totalPages}`;
|
||||
prevBtn.style.visibility = pageNumber > 1 ? 'visible' : 'hidden';
|
||||
nextBtn.style.visibility =
|
||||
pageNumber < state.totalPages ? 'visible' : 'hidden';
|
||||
|
||||
try {
|
||||
const page = await state.pdfjsDoc.getPage(pageNumber);
|
||||
const scale = 2.0;
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.className =
|
||||
'max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl';
|
||||
canvas.style.width = 'auto';
|
||||
canvas.style.height = 'auto';
|
||||
canvas.style.maxWidth = '90vw';
|
||||
canvas.style.maxHeight = '85vh';
|
||||
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
await page.render({ canvasContext: ctx, viewport, canvas }).promise;
|
||||
|
||||
container.innerHTML = '';
|
||||
container.appendChild(canvas);
|
||||
state.currentPage = pageNumber;
|
||||
} catch (err) {
|
||||
console.error('Preview render error:', err);
|
||||
container.innerHTML =
|
||||
'<div class="text-red-400 text-sm">Failed to render page</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function navigatePage(delta: number): void {
|
||||
const newPage = state.currentPage + delta;
|
||||
if (newPage >= 1 && newPage <= state.totalPages) {
|
||||
renderPreviewPage(newPage);
|
||||
}
|
||||
}
|
||||
|
||||
export function showPreview(
|
||||
pdfjsDoc: PDFDocumentProxy,
|
||||
pageNumber: number,
|
||||
totalPages: number
|
||||
): void {
|
||||
state.pdfjsDoc = pdfjsDoc;
|
||||
state.totalPages = totalPages;
|
||||
state.isOpen = true;
|
||||
|
||||
const modal = getOrCreateModal();
|
||||
modal.classList.remove('opacity-0', 'pointer-events-none');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
renderPreviewPage(pageNumber);
|
||||
}
|
||||
|
||||
export function hidePreview(): void {
|
||||
if (!state.modal) return;
|
||||
state.isOpen = false;
|
||||
state.modal.classList.add('opacity-0', 'pointer-events-none');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent): void {
|
||||
if (!state.isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
hidePreview();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
navigatePage(-1);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
navigatePage(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
|
||||
export function initPagePreview(
|
||||
container: HTMLElement,
|
||||
pdfjsDoc: PDFDocumentProxy,
|
||||
options: { pageAttr?: string } = {}
|
||||
): void {
|
||||
const totalPages = pdfjsDoc.numPages;
|
||||
|
||||
const thumbnails = container.querySelectorAll<HTMLElement>(
|
||||
'[data-page-number], [data-page-index], [data-pageIndex]'
|
||||
);
|
||||
|
||||
thumbnails.forEach((thumb) => {
|
||||
if (thumb.dataset.previewInit) return;
|
||||
thumb.dataset.previewInit = 'true';
|
||||
|
||||
let pageNum = 1;
|
||||
if (thumb.dataset.pageNumber) {
|
||||
pageNum = parseInt(thumb.dataset.pageNumber, 10);
|
||||
} else if (thumb.dataset.pageIndex !== undefined) {
|
||||
pageNum = parseInt(thumb.dataset.pageIndex, 10) + 1;
|
||||
}
|
||||
|
||||
const icon = document.createElement('button');
|
||||
icon.className =
|
||||
'page-preview-btn absolute bottom-1 right-1 bg-gray-900/80 hover:bg-indigo-600 text-white/70 hover:text-white rounded-full w-7 h-7 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-10';
|
||||
icon.title = 'Preview';
|
||||
icon.innerHTML =
|
||||
'<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>';
|
||||
icon.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
showPreview(pdfjsDoc, pageNum, totalPages);
|
||||
});
|
||||
|
||||
if (!thumb.classList.contains('relative')) {
|
||||
thumb.classList.add('relative');
|
||||
}
|
||||
if (!thumb.classList.contains('group')) {
|
||||
thumb.classList.add('group');
|
||||
}
|
||||
|
||||
thumb.appendChild(icon);
|
||||
});
|
||||
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.key === ' ' && !state.isOpen) {
|
||||
const hovered = container.querySelector<HTMLElement>(
|
||||
'[data-preview-init]:hover'
|
||||
);
|
||||
if (hovered) {
|
||||
e.preventDefault();
|
||||
let pageNum = 1;
|
||||
if (hovered.dataset.pageNumber) {
|
||||
pageNum = parseInt(hovered.dataset.pageNumber, 10);
|
||||
} else if (hovered.dataset.pageIndex !== undefined) {
|
||||
pageNum = parseInt(hovered.dataset.pageIndex, 10) + 1;
|
||||
}
|
||||
showPreview(pdfjsDoc, pageNum, totalPages);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export async function mergePdfs(
|
||||
);
|
||||
copiedPages.forEach((page) => mergedDoc.addPage(page));
|
||||
}
|
||||
return new Uint8Array(await mergedDoc.save());
|
||||
return new Uint8Array(await mergedDoc.save({ addDefaultPage: false }));
|
||||
}
|
||||
|
||||
export async function splitPdf(
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface RenderConfig {
|
||||
*/
|
||||
interface PageTask {
|
||||
pageNumber: number;
|
||||
pdfjsDoc: any;
|
||||
pdfjsDoc: pdfjsLib.PDFDocumentProxy;
|
||||
fileName?: string;
|
||||
container: HTMLElement;
|
||||
scale?: number;
|
||||
@@ -92,9 +92,9 @@ export function createPlaceholder(
|
||||
* Renders a single page to canvas
|
||||
*/
|
||||
export async function renderPageToCanvas(
|
||||
pdfjsDoc: any,
|
||||
pdfjsDoc: pdfjsLib.PDFDocumentProxy,
|
||||
pageNumber: number,
|
||||
scale: number = 0.5
|
||||
scale: number = 1
|
||||
): Promise<HTMLCanvasElement> {
|
||||
const page = await pdfjsDoc.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale });
|
||||
@@ -260,7 +260,7 @@ function requestIdleCallbackPolyfill(callback: () => void): void {
|
||||
* Main function to render pages progressively with optional lazy loading
|
||||
*/
|
||||
export async function renderPagesProgressively(
|
||||
pdfjsDoc: any,
|
||||
pdfjsDoc: pdfjsLib.PDFDocumentProxy,
|
||||
container: HTMLElement,
|
||||
createWrapper: (
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -298,7 +298,7 @@ export async function renderPagesProgressively(
|
||||
pageNumber: i,
|
||||
pdfjsDoc,
|
||||
container,
|
||||
scale: useLazyLoading ? 0.3 : 0.5,
|
||||
scale: useLazyLoading ? 0.5 : 1,
|
||||
createWrapper,
|
||||
placeholderElement: placeholders[i - 1],
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
|
||||
|
||||
private async isPageBlank(
|
||||
page: pdfjsLib.PDFPageProxy,
|
||||
threshold: number
|
||||
maxNonWhitePercent: number
|
||||
): Promise<boolean> {
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -34,12 +34,14 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
let totalBrightness = 0;
|
||||
const totalPixels = data.length / 4;
|
||||
let nonWhitePixels = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
totalBrightness += (data[i] + data[i + 1] + data[i + 2]) / 3;
|
||||
const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
||||
if (brightness < 240) nonWhitePixels++;
|
||||
}
|
||||
const avgBrightness = totalBrightness / (data.length / 4);
|
||||
return avgBrightness > threshold;
|
||||
const nonWhitePercent = (nonWhitePixels / totalPixels) * 100;
|
||||
return nonWhitePercent <= maxNonWhitePercent;
|
||||
}
|
||||
|
||||
async data(
|
||||
@@ -50,7 +52,10 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
|
||||
const threshCtrl = this.controls['threshold'] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const threshold = Math.max(200, Math.min(255, threshCtrl?.value ?? 250));
|
||||
const maxNonWhitePercent = Math.max(
|
||||
0.1,
|
||||
Math.min(5, threshCtrl?.value ?? 0.5)
|
||||
);
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
@@ -61,7 +66,7 @@ export class RemoveBlankPagesNode extends BaseWorkflowNode {
|
||||
|
||||
for (let i = 1; i <= pdfjsDoc.numPages; i++) {
|
||||
const page = await pdfjsDoc.getPage(i);
|
||||
const blank = await this.isPageBlank(page, threshold);
|
||||
const blank = await this.isPageBlank(page, maxNonWhitePercent);
|
||||
if (!blank) {
|
||||
nonBlankIndices.push(i - 1);
|
||||
} else {
|
||||
|
||||
@@ -182,24 +182,25 @@
|
||||
></div>
|
||||
|
||||
<!-- Advanced Settings -->
|
||||
<div id="advanced-settings" class="hidden mt-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">
|
||||
Advanced Settings
|
||||
</h3>
|
||||
<div class="bg-gray-700 p-4 rounded-lg">
|
||||
<div id="advanced-settings" class="hidden mt-6 space-y-3">
|
||||
<h3 class="text-lg font-semibold text-white">Advanced Settings</h3>
|
||||
<div class="bg-gray-800/50 p-4 rounded-lg border border-gray-700">
|
||||
<label
|
||||
for="page-order-input"
|
||||
class="block text-sm font-medium text-gray-300 mb-2"
|
||||
>Page Order (comma-separated)</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
id="page-order-input"
|
||||
class="w-full bg-gray-900 text-white rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 border border-gray-600"
|
||||
class="flex-1 bg-gray-900 text-white rounded-lg px-3 py-2.5 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 border border-gray-600 placeholder-gray-500"
|
||||
placeholder="e.g., 3,1,4,2"
|
||||
/>
|
||||
<button id="apply-order-btn" class="btn-secondary">
|
||||
<button
|
||||
id="apply-order-btn"
|
||||
class="btn-gradient whitespace-nowrap !px-5 !py-2.5 text-sm"
|
||||
>
|
||||
Apply Order
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -193,8 +193,12 @@
|
||||
step="5"
|
||||
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Higher values detect more pages as blank
|
||||
<p
|
||||
class="text-xs text-gray-400 mt-1"
|
||||
data-i18n="tools:removeBlankPages.sensitivityHint"
|
||||
>
|
||||
Higher = stricter, only purely blank pages. Lower = allows pages
|
||||
with some content.
|
||||
</p>
|
||||
</div>
|
||||
<button id="detect-btn" class="btn-gradient w-full mt-4">
|
||||
@@ -210,7 +214,7 @@
|
||||
<p id="preview-info" class="text-gray-400 text-sm mb-4"></p>
|
||||
<div
|
||||
id="blank-pages-preview"
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-3 max-h-64 overflow-y-auto p-2 bg-gray-900 rounded-lg border border-gray-700"
|
||||
class="grid grid-cols-3 md:grid-cols-4 gap-3 p-2 bg-gray-900 rounded-lg border border-gray-700"
|
||||
></div>
|
||||
<button id="process-btn" class="btn-gradient w-full mt-4">
|
||||
Remove Selected Blank Pages
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseBBox,
|
||||
parseBaseline,
|
||||
parseTextangle,
|
||||
getTextDirection,
|
||||
shouldInjectWordBreaks,
|
||||
normalizeText,
|
||||
calculateWordTransform,
|
||||
calculateSpaceTransform,
|
||||
} from '../js/utils/hocr-transform';
|
||||
|
||||
describe('hocr-transform', () => {
|
||||
describe('parseBBox', () => {
|
||||
it('should parse valid bbox string', () => {
|
||||
expect(parseBBox('bbox 100 200 300 400')).toEqual({
|
||||
x0: 100,
|
||||
y0: 200,
|
||||
x1: 300,
|
||||
y1: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse bbox with other attributes', () => {
|
||||
expect(parseBBox('bbox 10 20 30 40; x_wconf 95')).toEqual({
|
||||
x0: 10,
|
||||
y0: 20,
|
||||
x1: 30,
|
||||
y1: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for missing bbox', () => {
|
||||
expect(parseBBox('x_wconf 95')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
expect(parseBBox('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse zero values', () => {
|
||||
expect(parseBBox('bbox 0 0 0 0')).toEqual({
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse large coordinates', () => {
|
||||
expect(parseBBox('bbox 0 0 2480 3508')).toEqual({
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
x1: 2480,
|
||||
y1: 3508,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBaseline', () => {
|
||||
it('should parse valid baseline', () => {
|
||||
expect(parseBaseline('baseline 0.012 -5')).toEqual({
|
||||
slope: 0.012,
|
||||
intercept: -5,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse zero baseline', () => {
|
||||
expect(parseBaseline('baseline 0 0')).toEqual({
|
||||
slope: 0,
|
||||
intercept: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse negative slope', () => {
|
||||
expect(parseBaseline('baseline -0.005 3')).toEqual({
|
||||
slope: -0.005,
|
||||
intercept: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return defaults for missing baseline', () => {
|
||||
expect(parseBaseline('bbox 100 200 300 400')).toEqual({
|
||||
slope: 0,
|
||||
intercept: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return defaults for empty string', () => {
|
||||
expect(parseBaseline('')).toEqual({
|
||||
slope: 0,
|
||||
intercept: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle baseline with other attributes', () => {
|
||||
expect(parseBaseline('bbox 10 20 30 40; baseline 0.1 -2')).toEqual({
|
||||
slope: 0.1,
|
||||
intercept: -2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextangle', () => {
|
||||
it('should parse valid textangle', () => {
|
||||
expect(parseTextangle('textangle 90')).toBe(90);
|
||||
});
|
||||
|
||||
it('should parse float textangle', () => {
|
||||
expect(parseTextangle('textangle 1.5')).toBe(1.5);
|
||||
});
|
||||
|
||||
it('should parse negative textangle', () => {
|
||||
expect(parseTextangle('textangle -45')).toBe(-45);
|
||||
});
|
||||
|
||||
it('should return 0 for missing textangle', () => {
|
||||
expect(parseTextangle('bbox 0 0 100 100')).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 for empty string', () => {
|
||||
expect(parseTextangle('')).toBe(0);
|
||||
});
|
||||
|
||||
it('should parse zero textangle', () => {
|
||||
expect(parseTextangle('textangle 0')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTextDirection', () => {
|
||||
it('should return rtl for dir="rtl" attribute', () => {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('dir', 'rtl');
|
||||
expect(getTextDirection(el)).toBe('rtl');
|
||||
});
|
||||
|
||||
it('should return ltr for dir="ltr" attribute', () => {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('dir', 'ltr');
|
||||
expect(getTextDirection(el)).toBe('ltr');
|
||||
});
|
||||
|
||||
it('should default to ltr when no dir attribute', () => {
|
||||
const el = document.createElement('div');
|
||||
expect(getTextDirection(el)).toBe('ltr');
|
||||
});
|
||||
|
||||
it('should default to ltr for unknown dir values', () => {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('dir', 'auto');
|
||||
expect(getTextDirection(el)).toBe('ltr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldInjectWordBreaks', () => {
|
||||
it('should return true for English', () => {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('lang', 'eng');
|
||||
expect(shouldInjectWordBreaks(el)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for no lang attribute', () => {
|
||||
const el = document.createElement('div');
|
||||
expect(shouldInjectWordBreaks(el)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for CJK languages', () => {
|
||||
const cjkLangs = ['chi_sim', 'chi_tra', 'jpn', 'kor', 'zh', 'ja', 'ko'];
|
||||
cjkLangs.forEach((lang) => {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('lang', lang);
|
||||
expect(shouldInjectWordBreaks(el)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true for non-CJK languages', () => {
|
||||
const langs = ['fra', 'deu', 'spa', 'ara', 'hin'];
|
||||
langs.forEach((lang) => {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('lang', lang);
|
||||
expect(shouldInjectWordBreaks(el)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeText', () => {
|
||||
it('should normalize NFKC', () => {
|
||||
expect(normalizeText('\ufb01')).toBe('fi');
|
||||
});
|
||||
|
||||
it('should keep ASCII text unchanged', () => {
|
||||
expect(normalizeText('hello')).toBe('hello');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(normalizeText('')).toBe('');
|
||||
});
|
||||
|
||||
it('should normalize full-width characters', () => {
|
||||
expect(normalizeText('\uff21')).toBe('A');
|
||||
});
|
||||
|
||||
it('should normalize superscript digits', () => {
|
||||
expect(normalizeText('\u00b2')).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateWordTransform', () => {
|
||||
const baseLine = {
|
||||
bbox: { x0: 0, y0: 100, x1: 500, y1: 130 },
|
||||
baseline: { slope: 0, intercept: 0 },
|
||||
textangle: 0,
|
||||
words: [],
|
||||
direction: 'ltr' as const,
|
||||
injectWordBreaks: true,
|
||||
};
|
||||
|
||||
it('should calculate position and font size', () => {
|
||||
const word = {
|
||||
text: 'Hello',
|
||||
bbox: { x0: 10, y0: 100, x1: 60, y1: 120 },
|
||||
confidence: 95,
|
||||
};
|
||||
const fontWidthFn = (_text: string, fontSize: number) => fontSize * 2.5;
|
||||
|
||||
const result = calculateWordTransform(word, baseLine, 800, fontWidthFn);
|
||||
|
||||
expect(result.x).toBe(10);
|
||||
expect(result.y).toBe(680);
|
||||
expect(result.fontSize).toBeGreaterThan(0);
|
||||
expect(result.horizontalScale).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should clamp font size to max 2x word height', () => {
|
||||
const word = {
|
||||
text: 'x',
|
||||
bbox: { x0: 0, y0: 0, x1: 1000, y1: 10 },
|
||||
confidence: 95,
|
||||
};
|
||||
const fontWidthFn = (_text: string, _fontSize: number) => 0.001;
|
||||
|
||||
const result = calculateWordTransform(word, baseLine, 800, fontWidthFn);
|
||||
expect(result.fontSize).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('should clamp font size to minimum 1', () => {
|
||||
const word = {
|
||||
text: 'x',
|
||||
bbox: { x0: 0, y0: 0, x1: 1, y1: 10 },
|
||||
confidence: 95,
|
||||
};
|
||||
const fontWidthFn = (_text: string, _fontSize: number) => 10000;
|
||||
|
||||
const result = calculateWordTransform(word, baseLine, 800, fontWidthFn);
|
||||
expect(result.fontSize).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should handle zero font width gracefully', () => {
|
||||
const word = {
|
||||
text: '',
|
||||
bbox: { x0: 0, y0: 0, x1: 50, y1: 20 },
|
||||
confidence: 95,
|
||||
};
|
||||
const fontWidthFn = () => 0;
|
||||
|
||||
const result = calculateWordTransform(word, baseLine, 800, fontWidthFn);
|
||||
expect(result.horizontalScale).toBe(1);
|
||||
});
|
||||
|
||||
it('should incorporate baseline slope into rotation', () => {
|
||||
const slopedLine = {
|
||||
...baseLine,
|
||||
baseline: { slope: 0.1, intercept: 0 },
|
||||
};
|
||||
const word = {
|
||||
text: 'Hi',
|
||||
bbox: { x0: 10, y0: 100, x1: 40, y1: 115 },
|
||||
confidence: 90,
|
||||
};
|
||||
const fontWidthFn = (_text: string, fontSize: number) => fontSize * 1.5;
|
||||
|
||||
const result = calculateWordTransform(word, slopedLine, 800, fontWidthFn);
|
||||
expect(result.rotation).not.toBe(0);
|
||||
});
|
||||
|
||||
it('should incorporate textangle into rotation', () => {
|
||||
const angledLine = { ...baseLine, textangle: 5 };
|
||||
const word = {
|
||||
text: 'Hi',
|
||||
bbox: { x0: 10, y0: 100, x1: 40, y1: 115 },
|
||||
confidence: 90,
|
||||
};
|
||||
const fontWidthFn = (_text: string, fontSize: number) => fontSize * 1.5;
|
||||
|
||||
const result = calculateWordTransform(word, angledLine, 800, fontWidthFn);
|
||||
expect(result.rotation).toBe(-5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateSpaceTransform', () => {
|
||||
const baseLine = {
|
||||
bbox: { x0: 0, y0: 100, x1: 500, y1: 130 },
|
||||
baseline: { slope: 0, intercept: 0 },
|
||||
textangle: 0,
|
||||
words: [],
|
||||
direction: 'ltr' as const,
|
||||
injectWordBreaks: true,
|
||||
};
|
||||
|
||||
it('should calculate space between two words', () => {
|
||||
const prev = {
|
||||
text: 'Hello',
|
||||
bbox: { x0: 10, y0: 100, x1: 60, y1: 120 },
|
||||
confidence: 95,
|
||||
};
|
||||
const next = {
|
||||
text: 'World',
|
||||
bbox: { x0: 70, y0: 100, x1: 130, y1: 120 },
|
||||
confidence: 95,
|
||||
};
|
||||
const spaceWidthFn = (fontSize: number) => fontSize * 0.3;
|
||||
|
||||
const result = calculateSpaceTransform(
|
||||
prev,
|
||||
next,
|
||||
baseLine,
|
||||
800,
|
||||
spaceWidthFn
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.x).toBe(60);
|
||||
expect(result!.horizontalScale).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return null when gap is zero or negative', () => {
|
||||
const prev = {
|
||||
text: 'Hello',
|
||||
bbox: { x0: 10, y0: 100, x1: 60, y1: 120 },
|
||||
confidence: 95,
|
||||
};
|
||||
const next = {
|
||||
text: 'World',
|
||||
bbox: { x0: 60, y0: 100, x1: 120, y1: 120 },
|
||||
confidence: 95,
|
||||
};
|
||||
const spaceWidthFn = (fontSize: number) => fontSize * 0.3;
|
||||
|
||||
expect(
|
||||
calculateSpaceTransform(prev, next, baseLine, 800, spaceWidthFn)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when overlapping words', () => {
|
||||
const prev = {
|
||||
text: 'Hello',
|
||||
bbox: { x0: 10, y0: 100, x1: 80, y1: 120 },
|
||||
confidence: 95,
|
||||
};
|
||||
const next = {
|
||||
text: 'World',
|
||||
bbox: { x0: 70, y0: 100, x1: 130, y1: 120 },
|
||||
confidence: 95,
|
||||
};
|
||||
const spaceWidthFn = (fontSize: number) => fontSize * 0.3;
|
||||
|
||||
expect(
|
||||
calculateSpaceTransform(prev, next, baseLine, 800, spaceWidthFn)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when space width function returns 0', () => {
|
||||
const prev = {
|
||||
text: 'Hello',
|
||||
bbox: { x0: 10, y0: 100, x1: 60, y1: 120 },
|
||||
confidence: 95,
|
||||
};
|
||||
const next = {
|
||||
text: 'World',
|
||||
bbox: { x0: 70, y0: 100, x1: 130, y1: 120 },
|
||||
confidence: 95,
|
||||
};
|
||||
|
||||
expect(
|
||||
calculateSpaceTransform(prev, next, baseLine, 800, () => 0)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should account for baseline intercept in y position', () => {
|
||||
const lineWithIntercept = {
|
||||
...baseLine,
|
||||
baseline: { slope: 0, intercept: -5 },
|
||||
};
|
||||
const prev = {
|
||||
text: 'A',
|
||||
bbox: { x0: 0, y0: 100, x1: 20, y1: 130 },
|
||||
confidence: 90,
|
||||
};
|
||||
const next = {
|
||||
text: 'B',
|
||||
bbox: { x0: 30, y0: 100, x1: 50, y1: 130 },
|
||||
confidence: 90,
|
||||
};
|
||||
const spaceWidthFn = (fontSize: number) => fontSize * 0.3;
|
||||
|
||||
const result = calculateSpaceTransform(
|
||||
prev,
|
||||
next,
|
||||
lineWithIntercept,
|
||||
800,
|
||||
spaceWidthFn
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.y).toBe(800 - 130 - -5);
|
||||
});
|
||||
|
||||
it('should use line height + intercept for font size', () => {
|
||||
const prev = {
|
||||
text: 'A',
|
||||
bbox: { x0: 0, y0: 100, x1: 20, y1: 130 },
|
||||
confidence: 90,
|
||||
};
|
||||
const next = {
|
||||
text: 'B',
|
||||
bbox: { x0: 30, y0: 100, x1: 50, y1: 130 },
|
||||
confidence: 90,
|
||||
};
|
||||
const spaceWidthFn = (fontSize: number) => fontSize * 0.3;
|
||||
|
||||
const result = calculateSpaceTransform(
|
||||
prev,
|
||||
next,
|
||||
baseLine,
|
||||
800,
|
||||
spaceWidthFn
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.fontSize).toBe(30);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
rgbToHsl,
|
||||
hslToRgb,
|
||||
applyGreyscale,
|
||||
applyInvertColors,
|
||||
} from '../js/utils/image-effects';
|
||||
|
||||
function createImageData(pixels: number[][]): ImageData {
|
||||
const data = new Uint8ClampedArray(pixels.flat());
|
||||
return {
|
||||
data,
|
||||
width: pixels.length,
|
||||
height: 1,
|
||||
colorSpace: 'srgb',
|
||||
} as ImageData;
|
||||
}
|
||||
|
||||
describe('image-effects', () => {
|
||||
describe('rgbToHsl', () => {
|
||||
it('should convert pure red', () => {
|
||||
const [h, s, l] = rgbToHsl(255, 0, 0);
|
||||
expect(h).toBeCloseTo(0, 2);
|
||||
expect(s).toBeCloseTo(1, 2);
|
||||
expect(l).toBeCloseTo(0.5, 2);
|
||||
});
|
||||
|
||||
it('should convert pure green', () => {
|
||||
const [h, s, l] = rgbToHsl(0, 255, 0);
|
||||
expect(h).toBeCloseTo(1 / 3, 2);
|
||||
expect(s).toBeCloseTo(1, 2);
|
||||
expect(l).toBeCloseTo(0.5, 2);
|
||||
});
|
||||
|
||||
it('should convert pure blue', () => {
|
||||
const [h, s, l] = rgbToHsl(0, 0, 255);
|
||||
expect(h).toBeCloseTo(2 / 3, 2);
|
||||
expect(s).toBeCloseTo(1, 2);
|
||||
expect(l).toBeCloseTo(0.5, 2);
|
||||
});
|
||||
|
||||
it('should convert white', () => {
|
||||
const [h, s, l] = rgbToHsl(255, 255, 255);
|
||||
expect(h).toBe(0);
|
||||
expect(s).toBe(0);
|
||||
expect(l).toBeCloseTo(1, 2);
|
||||
});
|
||||
|
||||
it('should convert black', () => {
|
||||
const [h, s, l] = rgbToHsl(0, 0, 0);
|
||||
expect(h).toBe(0);
|
||||
expect(s).toBe(0);
|
||||
expect(l).toBe(0);
|
||||
});
|
||||
|
||||
it('should convert mid gray', () => {
|
||||
const [h, s, l] = rgbToHsl(128, 128, 128);
|
||||
expect(h).toBe(0);
|
||||
expect(s).toBe(0);
|
||||
expect(l).toBeCloseTo(0.502, 2);
|
||||
});
|
||||
|
||||
it('should convert yellow', () => {
|
||||
const [h, s, l] = rgbToHsl(255, 255, 0);
|
||||
expect(h).toBeCloseTo(1 / 6, 2);
|
||||
expect(s).toBeCloseTo(1, 2);
|
||||
expect(l).toBeCloseTo(0.5, 2);
|
||||
});
|
||||
|
||||
it('should convert cyan', () => {
|
||||
const [h, s, l] = rgbToHsl(0, 255, 255);
|
||||
expect(h).toBeCloseTo(0.5, 2);
|
||||
expect(s).toBeCloseTo(1, 2);
|
||||
expect(l).toBeCloseTo(0.5, 2);
|
||||
});
|
||||
|
||||
it('should convert magenta', () => {
|
||||
const [h, s, l] = rgbToHsl(255, 0, 255);
|
||||
expect(h).toBeCloseTo(5 / 6, 2);
|
||||
expect(s).toBeCloseTo(1, 2);
|
||||
expect(l).toBeCloseTo(0.5, 2);
|
||||
});
|
||||
|
||||
it('should handle dark colors (l < 0.5)', () => {
|
||||
const [h, s, l] = rgbToHsl(128, 0, 0);
|
||||
expect(h).toBeCloseTo(0, 2);
|
||||
expect(s).toBeCloseTo(1, 2);
|
||||
expect(l).toBeCloseTo(0.251, 2);
|
||||
});
|
||||
|
||||
it('should handle light colors (l > 0.5)', () => {
|
||||
const [h, s, l] = rgbToHsl(255, 128, 128);
|
||||
expect(l).toBeGreaterThan(0.5);
|
||||
expect(s).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hslToRgb', () => {
|
||||
it('should convert pure red', () => {
|
||||
expect(hslToRgb(0, 1, 0.5)).toEqual([255, 0, 0]);
|
||||
});
|
||||
|
||||
it('should convert pure green', () => {
|
||||
expect(hslToRgb(1 / 3, 1, 0.5)).toEqual([0, 255, 0]);
|
||||
});
|
||||
|
||||
it('should convert pure blue', () => {
|
||||
expect(hslToRgb(2 / 3, 1, 0.5)).toEqual([0, 0, 255]);
|
||||
});
|
||||
|
||||
it('should convert white', () => {
|
||||
expect(hslToRgb(0, 0, 1)).toEqual([255, 255, 255]);
|
||||
});
|
||||
|
||||
it('should convert black', () => {
|
||||
expect(hslToRgb(0, 0, 0)).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('should convert gray (zero saturation)', () => {
|
||||
const [r, g, b] = hslToRgb(0, 0, 0.5);
|
||||
expect(r).toBe(g);
|
||||
expect(g).toBe(b);
|
||||
expect(r).toBe(128);
|
||||
});
|
||||
|
||||
it('should convert yellow', () => {
|
||||
expect(hslToRgb(1 / 6, 1, 0.5)).toEqual([255, 255, 0]);
|
||||
});
|
||||
|
||||
it('should convert cyan', () => {
|
||||
expect(hslToRgb(0.5, 1, 0.5)).toEqual([0, 255, 255]);
|
||||
});
|
||||
|
||||
it('should handle different saturation values', () => {
|
||||
const [r1] = hslToRgb(0, 0.5, 0.5);
|
||||
const [r2] = hslToRgb(0, 1, 0.5);
|
||||
expect(r2).toBeGreaterThan(r1);
|
||||
});
|
||||
|
||||
it('should handle l < 0.5 branch', () => {
|
||||
const result = hslToRgb(0, 1, 0.25);
|
||||
expect(result[0]).toBe(128);
|
||||
expect(result[1]).toBe(0);
|
||||
expect(result[2]).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle l >= 0.5 branch', () => {
|
||||
const result = hslToRgb(0, 1, 0.75);
|
||||
expect(result[0]).toBe(255);
|
||||
expect(result[1]).toBe(128);
|
||||
expect(result[2]).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rgbToHsl <-> hslToRgb round-trip', () => {
|
||||
const testColors: [number, number, number][] = [
|
||||
[255, 0, 0],
|
||||
[0, 255, 0],
|
||||
[0, 0, 255],
|
||||
[255, 255, 0],
|
||||
[0, 255, 255],
|
||||
[255, 0, 255],
|
||||
[128, 128, 128],
|
||||
[200, 100, 50],
|
||||
[50, 150, 200],
|
||||
[10, 10, 10],
|
||||
[245, 245, 245],
|
||||
];
|
||||
|
||||
testColors.forEach(([r, g, b]) => {
|
||||
it(`should round-trip rgb(${r}, ${g}, ${b})`, () => {
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
const [r2, g2, b2] = hslToRgb(h, s, l);
|
||||
expect(r2).toBeCloseTo(r, 0);
|
||||
expect(g2).toBeCloseTo(g, 0);
|
||||
expect(b2).toBeCloseTo(b, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyGreyscale', () => {
|
||||
it('should convert colored pixel to grey using luminance weights', () => {
|
||||
const imageData = createImageData([[255, 0, 0, 255]]);
|
||||
applyGreyscale(imageData);
|
||||
const expected = Math.round(0.299 * 255);
|
||||
expect(imageData.data[0]).toBe(expected);
|
||||
expect(imageData.data[1]).toBe(expected);
|
||||
expect(imageData.data[2]).toBe(expected);
|
||||
expect(imageData.data[3]).toBe(255);
|
||||
});
|
||||
|
||||
it('should keep white as white', () => {
|
||||
const imageData = createImageData([[255, 255, 255, 255]]);
|
||||
applyGreyscale(imageData);
|
||||
expect(imageData.data[0]).toBe(255);
|
||||
expect(imageData.data[1]).toBe(255);
|
||||
expect(imageData.data[2]).toBe(255);
|
||||
});
|
||||
|
||||
it('should keep black as black', () => {
|
||||
const imageData = createImageData([[0, 0, 0, 255]]);
|
||||
applyGreyscale(imageData);
|
||||
expect(imageData.data[0]).toBe(0);
|
||||
expect(imageData.data[1]).toBe(0);
|
||||
expect(imageData.data[2]).toBe(0);
|
||||
});
|
||||
|
||||
it('should process multiple pixels', () => {
|
||||
const imageData = createImageData([
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 255],
|
||||
[0, 0, 255, 255],
|
||||
]);
|
||||
applyGreyscale(imageData);
|
||||
expect(imageData.data[0]).toBe(imageData.data[1]);
|
||||
expect(imageData.data[4]).toBe(imageData.data[5]);
|
||||
expect(imageData.data[8]).toBe(imageData.data[9]);
|
||||
});
|
||||
|
||||
it('should not modify alpha channel', () => {
|
||||
const imageData = createImageData([[100, 150, 200, 128]]);
|
||||
applyGreyscale(imageData);
|
||||
expect(imageData.data[3]).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyInvertColors', () => {
|
||||
it('should invert black to white', () => {
|
||||
const imageData = createImageData([[0, 0, 0, 255]]);
|
||||
applyInvertColors(imageData);
|
||||
expect(imageData.data[0]).toBe(255);
|
||||
expect(imageData.data[1]).toBe(255);
|
||||
expect(imageData.data[2]).toBe(255);
|
||||
});
|
||||
|
||||
it('should invert white to black', () => {
|
||||
const imageData = createImageData([[255, 255, 255, 255]]);
|
||||
applyInvertColors(imageData);
|
||||
expect(imageData.data[0]).toBe(0);
|
||||
expect(imageData.data[1]).toBe(0);
|
||||
expect(imageData.data[2]).toBe(0);
|
||||
});
|
||||
|
||||
it('should invert mid-range colors', () => {
|
||||
const imageData = createImageData([[100, 150, 200, 255]]);
|
||||
applyInvertColors(imageData);
|
||||
expect(imageData.data[0]).toBe(155);
|
||||
expect(imageData.data[1]).toBe(105);
|
||||
expect(imageData.data[2]).toBe(55);
|
||||
});
|
||||
|
||||
it('should not modify alpha channel', () => {
|
||||
const imageData = createImageData([[100, 150, 200, 128]]);
|
||||
applyInvertColors(imageData);
|
||||
expect(imageData.data[3]).toBe(128);
|
||||
});
|
||||
|
||||
it('should be its own inverse (double invert = original)', () => {
|
||||
const imageData = createImageData([[42, 128, 200, 255]]);
|
||||
applyInvertColors(imageData);
|
||||
applyInvertColors(imageData);
|
||||
expect(imageData.data[0]).toBe(42);
|
||||
expect(imageData.data[1]).toBe(128);
|
||||
expect(imageData.data[2]).toBe(200);
|
||||
});
|
||||
|
||||
it('should process multiple pixels', () => {
|
||||
const imageData = createImageData([
|
||||
[0, 0, 0, 255],
|
||||
[255, 255, 255, 255],
|
||||
]);
|
||||
applyInvertColors(imageData);
|
||||
expect(imageData.data[0]).toBe(255);
|
||||
expect(imageData.data[4]).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parsePageRange,
|
||||
parseDeletePages,
|
||||
mergePdfs,
|
||||
splitPdf,
|
||||
deletePdfPages,
|
||||
rotatePdfUniform,
|
||||
rotatePdfPages,
|
||||
} from '../js/utils/pdf-operations';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
async function createTestPdf(pageCount: number): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
for (let i = 0; i < pageCount; i++) {
|
||||
doc.addPage([612, 792]);
|
||||
}
|
||||
return new Uint8Array(await doc.save());
|
||||
}
|
||||
|
||||
describe('pdf-operations', () => {
|
||||
describe('parsePageRange', () => {
|
||||
it('should parse single page', () => {
|
||||
expect(parsePageRange('3', 10)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('should parse multiple pages', () => {
|
||||
expect(parsePageRange('1,3,5', 10)).toEqual([0, 2, 4]);
|
||||
});
|
||||
|
||||
it('should parse range', () => {
|
||||
expect(parsePageRange('2-5', 10)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should parse mixed pages and ranges', () => {
|
||||
expect(parsePageRange('1,3-5,8', 10)).toEqual([0, 2, 3, 4, 7]);
|
||||
});
|
||||
|
||||
it('should handle spaces', () => {
|
||||
expect(parsePageRange(' 1 , 3 - 5 ', 10)).toEqual([0, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should deduplicate and sort', () => {
|
||||
expect(parsePageRange('5,3,1,3-5', 10)).toEqual([0, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should clamp start to 1', () => {
|
||||
expect(parsePageRange('0-3', 10)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('should clamp end to totalPages', () => {
|
||||
expect(parsePageRange('8-15', 10)).toEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('should ignore pages outside bounds', () => {
|
||||
expect(parsePageRange('0,11,5', 10)).toEqual([4]);
|
||||
});
|
||||
|
||||
it('should handle single page document', () => {
|
||||
expect(parsePageRange('1', 1)).toEqual([0]);
|
||||
});
|
||||
|
||||
it('should handle invalid non-numeric input', () => {
|
||||
expect(parsePageRange('abc', 10)).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty parts', () => {
|
||||
expect(parsePageRange('1,,3', 10)).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it('should handle range with invalid end (NaN defaults to totalPages)', () => {
|
||||
expect(parsePageRange('3-abc', 10)).toEqual([2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDeletePages', () => {
|
||||
it('should parse single page', () => {
|
||||
expect(parseDeletePages('3', 10)).toEqual(new Set([3]));
|
||||
});
|
||||
|
||||
it('should parse multiple pages', () => {
|
||||
expect(parseDeletePages('1,3,5', 10)).toEqual(new Set([1, 3, 5]));
|
||||
});
|
||||
|
||||
it('should parse range', () => {
|
||||
expect(parseDeletePages('2-5', 10)).toEqual(new Set([2, 3, 4, 5]));
|
||||
});
|
||||
|
||||
it('should parse mixed pages and ranges', () => {
|
||||
expect(parseDeletePages('1,3-5,8', 10)).toEqual(new Set([1, 3, 4, 5, 8]));
|
||||
});
|
||||
|
||||
it('should ignore out-of-bounds pages', () => {
|
||||
expect(parseDeletePages('0,11,5', 10)).toEqual(new Set([5]));
|
||||
});
|
||||
|
||||
it('should handle spaces', () => {
|
||||
expect(parseDeletePages(' 1 , 3 ', 10)).toEqual(new Set([1, 3]));
|
||||
});
|
||||
|
||||
it('should clamp range to valid bounds', () => {
|
||||
expect(parseDeletePages('0-3', 10)).toEqual(new Set([1, 2, 3]));
|
||||
});
|
||||
|
||||
it('should clamp range end to totalPages', () => {
|
||||
expect(parseDeletePages('8-15', 10)).toEqual(new Set([8, 9, 10]));
|
||||
});
|
||||
|
||||
it('should return 1-indexed page numbers', () => {
|
||||
const result = parseDeletePages('1', 10);
|
||||
expect(result.has(1)).toBe(true);
|
||||
expect(result.has(0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty parts gracefully', () => {
|
||||
expect(parseDeletePages('1,,5', 10)).toEqual(new Set([1, 5]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergePdfs', () => {
|
||||
it('should merge two single-page PDFs', async () => {
|
||||
const pdf1 = await createTestPdf(1);
|
||||
const pdf2 = await createTestPdf(1);
|
||||
const merged = await mergePdfs([pdf1, pdf2]);
|
||||
const doc = await PDFDocument.load(merged);
|
||||
expect(doc.getPageCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should merge multiple PDFs', async () => {
|
||||
const pdfs = await Promise.all([
|
||||
createTestPdf(2),
|
||||
createTestPdf(3),
|
||||
createTestPdf(1),
|
||||
]);
|
||||
const merged = await mergePdfs(pdfs);
|
||||
const doc = await PDFDocument.load(merged);
|
||||
expect(doc.getPageCount()).toBe(6);
|
||||
});
|
||||
|
||||
it('should handle single PDF input', async () => {
|
||||
const pdf = await createTestPdf(3);
|
||||
const merged = await mergePdfs([pdf]);
|
||||
const doc = await PDFDocument.load(merged);
|
||||
expect(doc.getPageCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle empty array', async () => {
|
||||
const merged = await mergePdfs([]);
|
||||
const doc = await PDFDocument.load(merged);
|
||||
expect(doc.getPageCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitPdf', () => {
|
||||
it('should extract specific pages', async () => {
|
||||
const pdf = await createTestPdf(5);
|
||||
const split = await splitPdf(pdf, [0, 2, 4]);
|
||||
const doc = await PDFDocument.load(split);
|
||||
expect(doc.getPageCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('should extract single page', async () => {
|
||||
const pdf = await createTestPdf(5);
|
||||
const split = await splitPdf(pdf, [2]);
|
||||
const doc = await PDFDocument.load(split);
|
||||
expect(doc.getPageCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle extracting all pages', async () => {
|
||||
const pdf = await createTestPdf(3);
|
||||
const split = await splitPdf(pdf, [0, 1, 2]);
|
||||
const doc = await PDFDocument.load(split);
|
||||
expect(doc.getPageCount()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePdfPages', () => {
|
||||
it('should delete specified pages', async () => {
|
||||
const pdf = await createTestPdf(5);
|
||||
const result = await deletePdfPages(pdf, new Set([1, 3]));
|
||||
const doc = await PDFDocument.load(result);
|
||||
expect(doc.getPageCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('should delete single page', async () => {
|
||||
const pdf = await createTestPdf(3);
|
||||
const result = await deletePdfPages(pdf, new Set([2]));
|
||||
const doc = await PDFDocument.load(result);
|
||||
expect(doc.getPageCount()).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw when deleting all pages', async () => {
|
||||
const pdf = await createTestPdf(2);
|
||||
await expect(deletePdfPages(pdf, new Set([1, 2]))).rejects.toThrow(
|
||||
'Cannot delete all pages'
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore out-of-range page numbers', async () => {
|
||||
const pdf = await createTestPdf(3);
|
||||
const result = await deletePdfPages(pdf, new Set([5, 10]));
|
||||
const doc = await PDFDocument.load(result);
|
||||
expect(doc.getPageCount()).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle deleting no pages (empty set)', async () => {
|
||||
const pdf = await createTestPdf(3);
|
||||
const result = await deletePdfPages(pdf, new Set());
|
||||
const doc = await PDFDocument.load(result);
|
||||
expect(doc.getPageCount()).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotatePdfUniform', () => {
|
||||
it('should rotate all pages by 90 degrees', async () => {
|
||||
const pdf = await createTestPdf(3);
|
||||
const rotated = await rotatePdfUniform(pdf, 90);
|
||||
const doc = await PDFDocument.load(rotated);
|
||||
expect(doc.getPageCount()).toBe(3);
|
||||
expect(doc.getPage(0).getRotation().angle).toBe(90);
|
||||
});
|
||||
|
||||
it('should rotate by 180 degrees', async () => {
|
||||
const pdf = await createTestPdf(2);
|
||||
const rotated = await rotatePdfUniform(pdf, 180);
|
||||
const doc = await PDFDocument.load(rotated);
|
||||
expect(doc.getPage(0).getRotation().angle).toBe(180);
|
||||
});
|
||||
|
||||
it('should handle 0 degree rotation', async () => {
|
||||
const pdf = await createTestPdf(2);
|
||||
const rotated = await rotatePdfUniform(pdf, 0);
|
||||
const doc = await PDFDocument.load(rotated);
|
||||
expect(doc.getPage(0).getRotation().angle).toBe(0);
|
||||
});
|
||||
|
||||
it('should rotate by 270 degrees', async () => {
|
||||
const pdf = await createTestPdf(1);
|
||||
const rotated = await rotatePdfUniform(pdf, 270);
|
||||
const doc = await PDFDocument.load(rotated);
|
||||
expect(doc.getPage(0).getRotation().angle).toBe(270);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotatePdfPages', () => {
|
||||
it('should rotate individual pages by different angles', async () => {
|
||||
const pdf = await createTestPdf(3);
|
||||
const rotated = await rotatePdfPages(pdf, [90, 0, 180]);
|
||||
const doc = await PDFDocument.load(rotated);
|
||||
expect(doc.getPage(0).getRotation().angle).toBe(90);
|
||||
expect(doc.getPage(1).getRotation().angle).toBe(0);
|
||||
expect(doc.getPage(2).getRotation().angle).toBe(180);
|
||||
});
|
||||
|
||||
it('should treat missing rotations as 0', async () => {
|
||||
const pdf = await createTestPdf(3);
|
||||
const rotated = await rotatePdfPages(pdf, [90]);
|
||||
const doc = await PDFDocument.load(rotated);
|
||||
expect(doc.getPage(0).getRotation().angle).toBe(90);
|
||||
expect(doc.getPage(1).getRotation().angle).toBe(0);
|
||||
expect(doc.getPage(2).getRotation().angle).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle all zeros', async () => {
|
||||
const pdf = await createTestPdf(2);
|
||||
const rotated = await rotatePdfPages(pdf, [0, 0]);
|
||||
const doc = await PDFDocument.load(rotated);
|
||||
expect(doc.getPage(0).getRotation().angle).toBe(0);
|
||||
expect(doc.getPage(1).getRotation().angle).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ describe('Tool Configuration Arrays', () => {
|
||||
|
||||
it('should have the correct number of tools', () => {
|
||||
// This acts as a snapshot test to catch unexpected additions/removals.
|
||||
expect(singlePdfLoadTools).toHaveLength(41);
|
||||
expect(singlePdfLoadTools).toHaveLength(42);
|
||||
});
|
||||
|
||||
it('should not contain any duplicate tools', () => {
|
||||
@@ -61,7 +61,7 @@ describe('Tool Configuration Arrays', () => {
|
||||
});
|
||||
|
||||
it('should have the correct number of tools', () => {
|
||||
expect(multiFileTools).toHaveLength(13);
|
||||
expect(multiFileTools).toHaveLength(18);
|
||||
});
|
||||
|
||||
it('should not contain any duplicate tools', () => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
getRotationState,
|
||||
updateRotationState,
|
||||
resetRotationState,
|
||||
initializeRotationState,
|
||||
} from '../js/utils/rotation-state';
|
||||
|
||||
describe('rotation-state', () => {
|
||||
beforeEach(() => {
|
||||
resetRotationState();
|
||||
});
|
||||
|
||||
describe('initializeRotationState', () => {
|
||||
it('should create array of zeros for given page count', () => {
|
||||
initializeRotationState(5);
|
||||
expect(getRotationState()).toEqual([0, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it('should handle single page', () => {
|
||||
initializeRotationState(1);
|
||||
expect(getRotationState()).toEqual([0]);
|
||||
});
|
||||
|
||||
it('should handle zero pages', () => {
|
||||
initializeRotationState(0);
|
||||
expect(getRotationState()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should reset previous state on re-initialization', () => {
|
||||
initializeRotationState(3);
|
||||
updateRotationState(0, 90);
|
||||
initializeRotationState(5);
|
||||
expect(getRotationState()).toEqual([0, 0, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRotationState', () => {
|
||||
it('should return empty array before initialization', () => {
|
||||
expect(getRotationState()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return readonly array', () => {
|
||||
initializeRotationState(3);
|
||||
const state = getRotationState();
|
||||
expect(Array.isArray(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reflect current state after updates', () => {
|
||||
initializeRotationState(3);
|
||||
updateRotationState(1, 180);
|
||||
expect(getRotationState()[1]).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRotationState', () => {
|
||||
it('should update rotation at valid index', () => {
|
||||
initializeRotationState(3);
|
||||
updateRotationState(0, 90);
|
||||
expect(getRotationState()[0]).toBe(90);
|
||||
});
|
||||
|
||||
it('should update to any rotation value', () => {
|
||||
initializeRotationState(3);
|
||||
updateRotationState(0, 90);
|
||||
updateRotationState(1, 180);
|
||||
updateRotationState(2, 270);
|
||||
expect(getRotationState()).toEqual([90, 180, 270]);
|
||||
});
|
||||
|
||||
it('should handle negative rotation', () => {
|
||||
initializeRotationState(2);
|
||||
updateRotationState(0, -90);
|
||||
expect(getRotationState()[0]).toBe(-90);
|
||||
});
|
||||
|
||||
it('should ignore negative index', () => {
|
||||
initializeRotationState(3);
|
||||
updateRotationState(-1, 90);
|
||||
expect(getRotationState()).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('should ignore out-of-bounds index', () => {
|
||||
initializeRotationState(3);
|
||||
updateRotationState(5, 90);
|
||||
expect(getRotationState()).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it('should ignore update on empty state', () => {
|
||||
updateRotationState(0, 90);
|
||||
expect(getRotationState()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should allow overwriting a previously set rotation', () => {
|
||||
initializeRotationState(2);
|
||||
updateRotationState(0, 90);
|
||||
updateRotationState(0, 180);
|
||||
expect(getRotationState()[0]).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetRotationState', () => {
|
||||
it('should clear all state', () => {
|
||||
initializeRotationState(5);
|
||||
updateRotationState(2, 270);
|
||||
resetRotationState();
|
||||
expect(getRotationState()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should be safe to call on empty state', () => {
|
||||
resetRotationState();
|
||||
expect(getRotationState()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should allow re-initialization after reset', () => {
|
||||
initializeRotationState(3);
|
||||
resetRotationState();
|
||||
initializeRotationState(2);
|
||||
expect(getRotationState()).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,18 @@
|
||||
import { afterEach, vi } from 'vitest';
|
||||
|
||||
class TestDOMMatrix {
|
||||
a = 1;
|
||||
b = 0;
|
||||
c = 0;
|
||||
d = 1;
|
||||
e = 0;
|
||||
f = 0;
|
||||
}
|
||||
|
||||
if (typeof globalThis.DOMMatrix === 'undefined') {
|
||||
globalThis.DOMMatrix = TestDOMMatrix as unknown as typeof DOMMatrix;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.head.innerHTML = '';
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
function groupByTagName(elements: Element[]): Record<string, Element[]> {
|
||||
const groups: Record<string, Element[]> = {};
|
||||
for (const element of elements) {
|
||||
const tagName = element.tagName;
|
||||
if (!groups[tagName]) {
|
||||
groups[tagName] = [];
|
||||
}
|
||||
groups[tagName].push(element);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function extractTableData(elements: Element[]): {
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
} {
|
||||
if (elements.length === 0) {
|
||||
return { headers: [], rows: [] };
|
||||
}
|
||||
const headerSet = new Set<string>();
|
||||
for (const element of elements) {
|
||||
for (const child of Array.from(element.children)) {
|
||||
headerSet.add(child.tagName);
|
||||
}
|
||||
}
|
||||
const headers = Array.from(headerSet);
|
||||
const rows: string[][] = [];
|
||||
for (const element of elements) {
|
||||
const row: string[] = [];
|
||||
for (const header of headers) {
|
||||
const child = element.querySelector(header);
|
||||
row.push(child?.textContent?.trim() || '');
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return { headers, rows };
|
||||
}
|
||||
|
||||
function extractKeyValuePairs(element: Element): string[][] {
|
||||
const pairs: string[][] = [];
|
||||
for (const child of Array.from(element.children)) {
|
||||
const key = child.tagName;
|
||||
const value = child.textContent?.trim() || '';
|
||||
if (value) {
|
||||
pairs.push([formatTitle(key), value]);
|
||||
}
|
||||
}
|
||||
for (const attr of Array.from(element.attributes)) {
|
||||
pairs.push([formatTitle(attr.name), attr.value]);
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function formatTitle(tagName: string): string {
|
||||
return tagName
|
||||
.replace(/[_-]/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function parseXml(xmlString: string): Document {
|
||||
return new DOMParser().parseFromString(xmlString, 'text/xml');
|
||||
}
|
||||
|
||||
describe('xml-to-pdf utilities', () => {
|
||||
describe('formatTitle', () => {
|
||||
it('should convert underscores to spaces and capitalize', () => {
|
||||
expect(formatTitle('first_name')).toBe('First Name');
|
||||
});
|
||||
|
||||
it('should convert hyphens to spaces and capitalize', () => {
|
||||
expect(formatTitle('last-name')).toBe('Last Name');
|
||||
});
|
||||
|
||||
it('should split camelCase', () => {
|
||||
expect(formatTitle('firstName')).toBe('First Name');
|
||||
});
|
||||
|
||||
it('should handle single word', () => {
|
||||
expect(formatTitle('name')).toBe('Name');
|
||||
});
|
||||
|
||||
it('should handle all caps', () => {
|
||||
expect(formatTitle('ID')).toBe('Id');
|
||||
});
|
||||
|
||||
it('should handle mixed separators', () => {
|
||||
expect(formatTitle('user_firstName')).toBe('User First Name');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(formatTitle('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle multiple underscores', () => {
|
||||
expect(formatTitle('a_b_c')).toBe('A B C');
|
||||
});
|
||||
|
||||
it('should lowercase subsequent characters', () => {
|
||||
expect(formatTitle('XML')).toBe('Xml');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupByTagName', () => {
|
||||
it('should group elements by tag name', () => {
|
||||
const doc = parseXml(
|
||||
'<root><item>1</item><item>2</item><other>3</other></root>'
|
||||
);
|
||||
const children = Array.from(doc.documentElement.children);
|
||||
const groups = groupByTagName(children);
|
||||
expect(Object.keys(groups)).toEqual(['item', 'other']);
|
||||
expect(groups['item'].length).toBe(2);
|
||||
expect(groups['other'].length).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(groupByTagName([])).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle single element', () => {
|
||||
const doc = parseXml('<root><item>1</item></root>');
|
||||
const children = Array.from(doc.documentElement.children);
|
||||
const groups = groupByTagName(children);
|
||||
expect(Object.keys(groups)).toEqual(['item']);
|
||||
expect(groups['item'].length).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle all same tag names', () => {
|
||||
const doc = parseXml('<root><row>1</row><row>2</row><row>3</row></root>');
|
||||
const children = Array.from(doc.documentElement.children);
|
||||
const groups = groupByTagName(children);
|
||||
expect(Object.keys(groups)).toEqual(['row']);
|
||||
expect(groups['row'].length).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle all different tag names', () => {
|
||||
const doc = parseXml('<root><a>1</a><b>2</b><c>3</c></root>');
|
||||
const children = Array.from(doc.documentElement.children);
|
||||
const groups = groupByTagName(children);
|
||||
expect(Object.keys(groups).length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTableData', () => {
|
||||
it('should extract headers and rows from elements', () => {
|
||||
const doc = parseXml(`
|
||||
<root>
|
||||
<person><name>Alice</name><age>30</age></person>
|
||||
<person><name>Bob</name><age>25</age></person>
|
||||
</root>
|
||||
`);
|
||||
const elements = Array.from(doc.querySelectorAll('person'));
|
||||
const { headers, rows } = extractTableData(elements);
|
||||
expect(headers).toEqual(['name', 'age']);
|
||||
expect(rows).toEqual([
|
||||
['Alice', '30'],
|
||||
['Bob', '25'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
expect(extractTableData([])).toEqual({ headers: [], rows: [] });
|
||||
});
|
||||
|
||||
it('should handle missing children in some elements', () => {
|
||||
const doc = parseXml(`
|
||||
<root>
|
||||
<item><a>1</a><b>2</b></item>
|
||||
<item><a>3</a></item>
|
||||
</root>
|
||||
`);
|
||||
const elements = Array.from(doc.querySelectorAll('item'));
|
||||
const { headers, rows } = extractTableData(elements);
|
||||
expect(headers).toEqual(['a', 'b']);
|
||||
expect(rows[1]).toEqual(['3', '']);
|
||||
});
|
||||
|
||||
it('should handle elements with no children', () => {
|
||||
const doc = parseXml('<root><item></item></root>');
|
||||
const elements = Array.from(doc.querySelectorAll('item'));
|
||||
const { headers, rows } = extractTableData(elements);
|
||||
expect(headers).toEqual([]);
|
||||
expect(rows).toEqual([[]]);
|
||||
});
|
||||
|
||||
it('should collect headers from all elements', () => {
|
||||
const doc = parseXml(`
|
||||
<root>
|
||||
<item><a>1</a></item>
|
||||
<item><b>2</b></item>
|
||||
</root>
|
||||
`);
|
||||
const elements = Array.from(doc.querySelectorAll('item'));
|
||||
const { headers } = extractTableData(elements);
|
||||
expect(headers).toContain('a');
|
||||
expect(headers).toContain('b');
|
||||
});
|
||||
|
||||
it('should trim whitespace from text content', () => {
|
||||
const doc = parseXml('<root><item><val> hello </val></item></root>');
|
||||
const elements = Array.from(doc.querySelectorAll('item'));
|
||||
const { rows } = extractTableData(elements);
|
||||
expect(rows[0][0]).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractKeyValuePairs', () => {
|
||||
it('should extract child elements as key-value pairs', () => {
|
||||
const doc = parseXml(
|
||||
'<config><name>Test</name><version>1.0</version></config>'
|
||||
);
|
||||
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||
expect(pairs).toEqual([
|
||||
['Name', 'Test'],
|
||||
['Version', '1.0'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract attributes as key-value pairs', () => {
|
||||
const doc = parseXml('<config id="123" type="main"></config>');
|
||||
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||
expect(pairs).toContainEqual(['Id', '123']);
|
||||
expect(pairs).toContainEqual(['Type', 'main']);
|
||||
});
|
||||
|
||||
it('should include both children and attributes', () => {
|
||||
const doc = parseXml('<config id="1"><name>Test</name></config>');
|
||||
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||
expect(pairs.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should skip empty child text content', () => {
|
||||
const doc = parseXml('<config><name>Test</name><empty></empty></config>');
|
||||
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||
expect(pairs.length).toBe(1);
|
||||
expect(pairs[0][0]).toBe('Name');
|
||||
});
|
||||
|
||||
it('should handle element with no children or attributes', () => {
|
||||
const doc = parseXml('<config></config>');
|
||||
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||
expect(pairs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should format tag names using formatTitle', () => {
|
||||
const doc = parseXml('<config><user_name>Alice</user_name></config>');
|
||||
const pairs = extractKeyValuePairs(doc.documentElement);
|
||||
expect(pairs[0][0]).toBe('User Name');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user