mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-05 11:52:48 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d9ffe2997 | ||
|
|
d32251014e | ||
|
|
c2147a2f20 | ||
|
|
90998edb25 | ||
|
|
84848ab902 | ||
|
|
2aaea50031 | ||
|
|
5fbf48601e | ||
|
|
4cec7bbe58 | ||
|
|
e505c70b2f | ||
|
|
841428f2e2 | ||
|
|
957b78fafd | ||
|
|
34f12655c1 | ||
|
|
74d7af283e | ||
|
|
5ef73edea2 | ||
|
|
9b099c6a6d | ||
|
|
146e3f07d4 | ||
|
|
e8563a1392 | ||
|
|
d66287a55b | ||
|
|
0695361469 | ||
|
|
88561b2fcb | ||
|
|
9686c38d7f | ||
|
|
5b7f1fc85f | ||
|
|
2c6d36037a |
+5
-1
@@ -49,7 +49,11 @@ ENV VITE_FOOTER_TEXT=$VITE_FOOTER_TEXT
|
||||
|
||||
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
-1
@@ -46,7 +46,11 @@ ENV VITE_FOOTER_TEXT=$VITE_FOOTER_TEXT
|
||||
|
||||
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
+802
-1130
File diff suppressed because it is too large
Load Diff
+30
-29
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "bento-pdf",
|
||||
"private": true,
|
||||
"version": "2.3.3",
|
||||
"version": "2.4.1",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -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,31 +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",
|
||||
"cropperjs": "^1.6.1",
|
||||
"bwip-js": "^4.8.0",
|
||||
"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",
|
||||
@@ -109,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",
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"filesNeverLeave": "Вашы файлы ніколі не пакідаюць прыладу.",
|
||||
"addMore": "Дадаць больш файлаў",
|
||||
"clearAll": "Ачысціць усё",
|
||||
"clearFiles": "Ачысціць файлы",
|
||||
"hints": {
|
||||
"singlePdf": "Адзін файл PDF",
|
||||
"pdfFile": "Файл PDF",
|
||||
@@ -253,7 +254,8 @@
|
||||
"error": "Памылка",
|
||||
"success": "Поспех",
|
||||
"file": "Файл",
|
||||
"files": "Файлы"
|
||||
"files": "Файлы",
|
||||
"close": "Зачыніць"
|
||||
},
|
||||
"about": {
|
||||
"hero": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,7 +22,29 @@
|
||||
},
|
||||
"compressPdf": {
|
||||
"name": "Comprimir PDF",
|
||||
"subtitle": "Reduza o tamanho do arquivo do seu PDF."
|
||||
"subtitle": "Reduza o tamanho do arquivo do seu PDF.",
|
||||
"algorithmLabel": "Algoritmo de Compressão",
|
||||
"condense": "Condense (Recomendado)",
|
||||
"photon": "Photon (Para PDFs com Muitas Fotos)",
|
||||
"condenseInfo": "Condense usa compressão avançada: remove dados desnecessários, otimiza imagens, subconjunta fontes. Ideal para a maioria dos PDFs.",
|
||||
"photonInfo": "Photon converte páginas em imagens. Use para PDFs com muitas fotos/digitalizados.",
|
||||
"photonWarning": "Aviso: O texto ficará não selecionável e os links deixarão de funcionar.",
|
||||
"levelLabel": "Nível de Compressão",
|
||||
"light": "Leve (Preservar Qualidade)",
|
||||
"balanced": "Equilibrado (Recomendado)",
|
||||
"aggressive": "Agressivo (Arquivos Menores)",
|
||||
"extreme": "Extremo (Compressão Máxima)",
|
||||
"grayscale": "Converter para Escala de Cinza",
|
||||
"grayscaleHint": "Reduz o tamanho do arquivo removendo informações de cor",
|
||||
"customSettings": "Configurações Personalizadas",
|
||||
"customSettingsHint": "Ajuste fino dos parâmetros de compressão:",
|
||||
"outputQuality": "Qualidade de Saída",
|
||||
"resizeImagesTo": "Redimensionar Imagens Para",
|
||||
"onlyProcessAbove": "Processar Apenas Acima de",
|
||||
"removeMetadata": "Remover metadados",
|
||||
"subsetFonts": "Subconjunto de fontes (remover glifos não utilizados)",
|
||||
"removeThumbnails": "Remover miniaturas incorporadas",
|
||||
"compressButton": "Comprimir PDF"
|
||||
},
|
||||
"pdfEditor": {
|
||||
"name": "Editor de PDF",
|
||||
@@ -141,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",
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"nav": {
|
||||
"home": "Hem",
|
||||
"about": "Om",
|
||||
"contact": "Kontakt",
|
||||
"licensing": "Licensiering",
|
||||
"allTools": "Alla verktyg",
|
||||
"openMainMenu": "Öppna huvudmenyn",
|
||||
"language": "Språk"
|
||||
},
|
||||
"donation": {
|
||||
"message": "Gillar du BentoPDF? Hjälp oss att hålla det gratis och öppen källkod!",
|
||||
"button": "Donera"
|
||||
},
|
||||
"hero": {
|
||||
"title": " ",
|
||||
"pdfToolkit": "PDF-verktygslådan",
|
||||
"builtForPrivacy": "byggd för integritet",
|
||||
"noSignups": "Inga konton",
|
||||
"unlimitedUse": "Obegränsad användning",
|
||||
"worksOffline": "Fungerar offline",
|
||||
"startUsing": "Börja använda nu"
|
||||
},
|
||||
"usedBy": {
|
||||
"title": "Används av företag och personer som arbetar på"
|
||||
},
|
||||
"features": {
|
||||
"title": "Varför välja",
|
||||
"bentoPdf": "BentoPDF?",
|
||||
"noSignup": {
|
||||
"title": "Ingen registrering",
|
||||
"description": "Börja direkt, inga konton eller e-postadresser."
|
||||
},
|
||||
"noUploads": {
|
||||
"title": "Inga uppladdningar",
|
||||
"description": "100% på klientsidan, dina filer lämnar aldrig din enhet."
|
||||
},
|
||||
"foreverFree": {
|
||||
"title": "Gratis för alltid",
|
||||
"description": "Alla verktyg, inga provperioder, inga betalväggar."
|
||||
},
|
||||
"noLimits": {
|
||||
"title": "Inga begränsningar",
|
||||
"description": "Använd så mycket du vill, inga dolda gränser."
|
||||
},
|
||||
"batchProcessing": {
|
||||
"title": "Batchbearbetning",
|
||||
"description": "Hantera obegränsade PDF-filer på en gång."
|
||||
},
|
||||
"lightningFast": {
|
||||
"title": "Blixtrande snabb",
|
||||
"description": "Bearbeta PDF-filer direkt, utan väntan eller förseningar."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"title": "Kom igång med",
|
||||
"toolsLabel": "verktyg",
|
||||
"subtitle": "Klicka på ett verktyg för att öppna filväljaren",
|
||||
"searchPlaceholder": "Sök efter ett verktyg (t.ex. 'dela', 'organisera'...)",
|
||||
"backToTools": "Tillbaka till verktyg",
|
||||
"firstLoadNotice": "Första laddningen tar ett ögonblick eftersom vi hämtar vår konverteringsmotor. Därefter blir alla laddningar omedelbara."
|
||||
},
|
||||
"upload": {
|
||||
"clickToSelect": "Klicka för att välja en fil",
|
||||
"orDragAndDrop": "eller dra och släpp",
|
||||
"pdfOrImages": "PDF-filer eller bilder",
|
||||
"filesNeverLeave": "Dina filer lämnar aldrig din enhet.",
|
||||
"addMore": "Lägg till fler filer",
|
||||
"clearAll": "Rensa allt",
|
||||
"clearFiles": "Rensa filer",
|
||||
"hints": {
|
||||
"singlePdf": "Ett enskilt PDF-dokument",
|
||||
"pdfFile": "PDF-fil",
|
||||
"multiplePdfs2": "Flera PDF-filer (minst 2)",
|
||||
"bmpImages": "BMP-bilder",
|
||||
"oneOrMorePdfs": "En eller flera PDF-filer",
|
||||
"pdfDocuments": "PDF-dokument",
|
||||
"oneOrMoreCsv": "En eller flera CSV-filer",
|
||||
"multiplePdfsSupported": "Flera PDF-filer stöds",
|
||||
"singleOrMultiplePdfs": "Enskild eller flera PDF-filer stöds",
|
||||
"singlePdfFile": "Enskild PDF-fil",
|
||||
"pdfWithForms": "PDF-fil med formulärfält",
|
||||
"heicImages": "HEIC/HEIF-bilder",
|
||||
"jpgImages": "JPG, JPEG, JP2, JPX-bilder",
|
||||
"pdfsOrImages": "PDF-filer eller bilder",
|
||||
"oneOrMoreOdt": "En eller flera ODT-filer",
|
||||
"singlePdfOnly": "Endast enskild PDF-fil",
|
||||
"pdfFiles": "PDF-filer",
|
||||
"multiplePdfs": "Flera PDF-filer",
|
||||
"pngImages": "PNG-bilder",
|
||||
"pdfFilesOneOrMore": "PDF-filer (en eller flera)",
|
||||
"oneOrMoreRtf": "En eller flera RTF-filer",
|
||||
"svgGraphics": "SVG-grafik",
|
||||
"tiffImages": "TIFF-bilder",
|
||||
"webpImages": "WebP-bilder"
|
||||
}
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "Så här fungerar det",
|
||||
"step1": "Klicka eller dra och släpp din fil för att börja",
|
||||
"step2": "Klicka på bearbeta-knappen för att starta",
|
||||
"step3": "Spara din bearbetade fil direkt"
|
||||
},
|
||||
"relatedTools": {
|
||||
"title": "Relaterade PDF-verktyg"
|
||||
},
|
||||
"loader": {
|
||||
"processing": "Bearbetar..."
|
||||
},
|
||||
"alert": {
|
||||
"title": "Varning",
|
||||
"ok": "OK"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Förhandsvisning",
|
||||
"downloadAsPdf": "Ladda ner som PDF",
|
||||
"close": "Stäng"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Inställningar",
|
||||
"shortcuts": "Genvägar",
|
||||
"preferences": "Inställningar",
|
||||
"displayPreferences": "Visningsinställningar",
|
||||
"searchShortcuts": "Sök genvägar...",
|
||||
"shortcutsInfo": "Håll ned tangenterna för att ställa in en genväg. Ändringar sparas automatiskt.",
|
||||
"shortcutsWarning": "⚠️ Undvik vanliga webbläsargenvägar (Cmd/Ctrl+W, Cmd/Ctrl+T, Cmd/Ctrl+N osv.) eftersom de kanske inte fungerar tillförlitligt.",
|
||||
"import": "Importera",
|
||||
"export": "Exportera",
|
||||
"resetToDefaults": "Återställ till standard",
|
||||
"fullWidthMode": "Fullbreddsläge",
|
||||
"fullWidthDescription": "Använd hela skärmbredden för alla verktyg istället för en centrerad behållare",
|
||||
"settingsAutoSaved": "Inställningar sparas automatiskt",
|
||||
"clickToSet": "Klicka för att ställa in",
|
||||
"pressKeys": "Tryck tangenter...",
|
||||
"warnings": {
|
||||
"alreadyInUse": "Genvägen används redan",
|
||||
"assignedTo": "är redan tilldelad till:",
|
||||
"chooseDifferent": "Välj en annan genväg.",
|
||||
"reserved": "Reserverad genvägsvarning",
|
||||
"commonlyUsed": "används vanligtvis för:",
|
||||
"unreliable": "Den här genvägen fungerar kanske inte tillförlitligt eller kan komma i konflikt med webbläsarens/systemets beteende.",
|
||||
"useAnyway": "Vill du använda den ändå?",
|
||||
"resetTitle": "Återställ genvägar",
|
||||
"resetMessage": "Är du säker på att du vill återställa alla genvägar till standard?<br><br>Den här åtgärden kan inte ångras.",
|
||||
"importSuccessTitle": "Import lyckades",
|
||||
"importSuccessMessage": "Genvägar importerades!",
|
||||
"importFailTitle": "Import misslyckades",
|
||||
"importFailMessage": "Det gick inte att importera genvägar. Ogiltigt filformat."
|
||||
}
|
||||
},
|
||||
"warning": {
|
||||
"title": "Varning",
|
||||
"cancel": "Avbryt",
|
||||
"proceed": "Fortsätt"
|
||||
},
|
||||
"compliance": {
|
||||
"title": "Dina data lämnar aldrig din enhet",
|
||||
"weKeep": "Vi håller",
|
||||
"yourInfoSafe": "din information säker",
|
||||
"byFollowingStandards": "genom att följa globala säkerhetsstandarder.",
|
||||
"processingLocal": "All bearbetning sker lokalt på din enhet.",
|
||||
"gdpr": {
|
||||
"title": "GDPR-efterlevnad",
|
||||
"description": "Skyddar personuppgifter och integritet för individer inom Europeiska unionen."
|
||||
},
|
||||
"ccpa": {
|
||||
"title": "CCPA-efterlevnad",
|
||||
"description": "Ger Kaliforniens residenter rättigheter över hur deras personliga information samlas in, används och delas."
|
||||
},
|
||||
"hipaa": {
|
||||
"title": "HIPAA-efterlevnad",
|
||||
"description": "Fastställer skyddsåtgärder för hantering av känslig hälsoinformation i det amerikanska sjukvårdssystemet."
|
||||
}
|
||||
},
|
||||
"faq": {
|
||||
"title": "Vanliga",
|
||||
"questions": "frågor",
|
||||
"sectionTitle": "Vanliga frågor och svar",
|
||||
"isFree": {
|
||||
"question": "Är BentoPDF verkligen gratis?",
|
||||
"answer": "Ja, absolut. Alla verktyg på BentoPDF är 100% gratis att använda, utan filgränser, utan registreringar och utan vattenstämplar. Vi tror att alla förtjänar tillgång till enkla, kraftfulla PDF-verktyg utan en betalvägg."
|
||||
},
|
||||
"areFilesSecure": {
|
||||
"question": "Är mina filer säkra? Var bearbetas de?",
|
||||
"answer": "Dina filer är så säkra som möjligt eftersom de aldrig lämnar din dator. All bearbetning sker direkt i din webbläsare (klientsida). Vi laddar aldrig upp dina filer till en server, så du har fullständig integritet och kontroll över dina dokument."
|
||||
},
|
||||
"platforms": {
|
||||
"question": "Fungerar det på Mac, Windows och mobil?",
|
||||
"answer": "Ja! Eftersom BentoPDF körs helt i din webbläsare fungerar det på alla operativsystem med en modern webbläsare, inklusive Windows, macOS, Linux, iOS och Android."
|
||||
},
|
||||
"gdprCompliant": {
|
||||
"question": "Är BentoPDF GDPR-kompatibelt?",
|
||||
"answer": "Ja. BentoPDF är fullt GDPR-kompatibelt. Eftersom all filbearbetning sker lokalt i din webbläsare och vi aldrig samlar in eller överför dina filer till någon server, har vi ingen tillgång till dina data. Detta säkerställer att du alltid har kontroll över dina dokument."
|
||||
},
|
||||
"dataStorage": {
|
||||
"question": "Lagrar eller spårar ni några av mina filer?",
|
||||
"answer": "Nej. Vi lagrar, spårar eller loggar aldrig dina filer. Allt du gör på BentoPDF händer i din webbläsares minne och försvinner när du stänger sidan. Det finns inga uppladdningar, inga historikloggar och inga servrar inblandade."
|
||||
},
|
||||
"different": {
|
||||
"question": "Vad gör BentoPDF annorlunda från andra PDF-verktyg?",
|
||||
"answer": "De flesta PDF-verktyg laddar upp dina filer till en server för bearbetning. BentoPDF gör aldrig det. Vi använder säker, modern webbteknik för att bearbeta dina filer direkt i din webbläsare. Detta innebär snabbare prestanda, starkare integritet och fullständig trygghet."
|
||||
},
|
||||
"browserBased": {
|
||||
"question": "Hur håller webbläsarbaserad bearbetning mig säker?",
|
||||
"answer": "Genom att köras helt inne i din webbläsare säkerställer BentoPDF att dina filer aldrig lämnar din enhet. Detta eliminerar riskerna för serverhack, dataintrång eller obehörig åtkomst. Dina filer förblir dina - alltid."
|
||||
},
|
||||
"analytics": {
|
||||
"question": "Använder ni cookies eller analys för att spåra mig?",
|
||||
"answer": "Vi bryr oss om din integritet. BentoPDF spårar inte personlig information. Vi använder Simple Analytics enbart för att se anonyma besöksantal. Det betyder att vi kan se hur många användare som besöker vår site, men vi vet aldrig vem du är. Simple Analytics är fullt GDPR-kompatibelt och respekterar din integritet."
|
||||
}
|
||||
},
|
||||
"testimonials": {
|
||||
"title": "Vad våra",
|
||||
"users": "användare",
|
||||
"say": "säger"
|
||||
},
|
||||
"support": {
|
||||
"title": "Gillar du mitt arbete?",
|
||||
"description": "BentoPDF är ett passionerat projekt, byggt för att erbjuda en gratis, privat och kraftfull PDF-verktygslåda för alla. Om du tycker det är användbart, överväg att stödja utvecklingen. Varje kaffe hjälper!",
|
||||
"buyMeCoffee": "Buy Me a Coffee"
|
||||
},
|
||||
"footer": {
|
||||
"copyright": "© 2026 BentoPDF. Alla rättigheter reserverade.",
|
||||
"version": "Version",
|
||||
"company": "Företag",
|
||||
"aboutUs": "Om oss",
|
||||
"faqLink": "FAQ",
|
||||
"contactUs": "Kontakta oss",
|
||||
"legal": "Juridiskt",
|
||||
"termsAndConditions": "Villkor",
|
||||
"privacyPolicy": "Integritetspolicy",
|
||||
"followUs": "Följ oss"
|
||||
},
|
||||
"merge": {
|
||||
"title": "Sammanfoga PDF:er",
|
||||
"description": "Kombinera hela filer eller välj specifika sidor att sammanfoga till ett nytt dokument.",
|
||||
"fileMode": "Filläge",
|
||||
"pageMode": "Sidoläge",
|
||||
"howItWorks": "Så här fungerar det:",
|
||||
"fileModeInstructions": [
|
||||
"Klicka och dra ikonen för att ändra ordningen på filerna.",
|
||||
"I rutan \"Sidor\" för varje fil kan du ange intervall (t.ex. \"1-3, 5\") för att sammanfoga endast de sidorna.",
|
||||
"Lämna rutan \"Sidor\" tom för att inkludera alla sidor från den filen."
|
||||
],
|
||||
"pageModeInstructions": [
|
||||
"Alla sidor från dina uppladdade PDF:er visas nedan.",
|
||||
"Dra och släpp sidminiyrbilderna för att skapa den ordning du vill ha för din nya fil."
|
||||
],
|
||||
"mergePdfs": "Sammanfoga PDF:er"
|
||||
},
|
||||
"common": {
|
||||
"page": "Sida",
|
||||
"pages": "Sidor",
|
||||
"of": "av",
|
||||
"download": "Ladda ner",
|
||||
"cancel": "Avbryt",
|
||||
"save": "Spara",
|
||||
"delete": "Ta bort",
|
||||
"edit": "Redigera",
|
||||
"add": "Lägg till",
|
||||
"remove": "Ta bort",
|
||||
"loading": "Laddar...",
|
||||
"error": "Fel",
|
||||
"success": "Klart",
|
||||
"file": "Fil",
|
||||
"files": "Filer",
|
||||
"close": "Stäng"
|
||||
},
|
||||
"about": {
|
||||
"hero": {
|
||||
"title": "Vi tror att PDF-verktyg ska vara",
|
||||
"subtitle": "snabba, privata och gratis.",
|
||||
"noCompromises": "Inga kompromisser."
|
||||
},
|
||||
"mission": {
|
||||
"title": "Vårt uppdrag",
|
||||
"description": "Att tillhandahålla den mest omfattande PDF-verktygslådan som respekterar din integritet och aldrig ber om betalning. Vi tycker att viktiga dokumentverktyg ska vara tillgängliga för alla, överallt, utan hinder."
|
||||
},
|
||||
"philosophy": {
|
||||
"label": "Vår kärnfilosofi",
|
||||
"title": "Integritet först. Alltid.",
|
||||
"description": "I en tid där data är en handelsvara tar vi en annan väg. All bearbetning i BentoPDF sker lokalt i din webbläsare. Detta dina filer betyder att aldrig rör våra servrar, vi ser aldrig dina dokument och vi spårar inte vad du gör. Dina dokument förblir helt och hållet privata. Det är inte bara en funktion - det är vår grund."
|
||||
},
|
||||
"whyBentopdf": {
|
||||
"title": "Varför",
|
||||
"speed": {
|
||||
"title": "Byggd för hastighet",
|
||||
"description": "Ingen väntan på uppladdningar eller nedladdningar till en server. Genom att bearbeta filer direkt i din webbläsare med moderna webbteknologier som WebAssembly erbjuder vi oöverträffad hastighet för alla våra verktyg."
|
||||
},
|
||||
"free": {
|
||||
"title": "Helt gratis",
|
||||
"description": "Inga provperioder, inga prenumerationer, inga dolda avgifter och inga \"premium\"-funktioner bakom en spärr. Vi tycker att kraftfulla PDF-verktyg ska vara en allmän nyttighet, inte en vinstmaskin."
|
||||
},
|
||||
"noAccount": {
|
||||
"title": "Inget konto krävs",
|
||||
"description": "Börja använda vilket verktyg som helst direkt. Vi behöver inte din e-post, ett lösenord eller någon personlig information. Ditt arbetsflöde ska vara friktionsfritt och anonymt."
|
||||
},
|
||||
"openSource": {
|
||||
"title": "Öppen källkod-anda",
|
||||
"description": "Byggd med transparens i åtanke. Vi använder fantastiska öppna källkodsbibliotek som PDF-lib och PDF.js, och tror på community-driven utveckling för att göra kraftfulla verktyg tillgängliga för alla."
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Redo att komma igång?",
|
||||
"description": "Gå med i tusentals användare som litar på Bentopdf för sina dagliga dokumentbehov. Upplev skillnaden som integritet och prestanda kan göra.",
|
||||
"button": "Utforska alla verktyg"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"title": "Kontakta oss",
|
||||
"subtitle": "Vi vill gärna höra från dig. Oavsett om du har frågor, feedback eller funktionsförslag - tveka inte att höra av dig.",
|
||||
"email": "Du kan nå oss direkt via e-post på:"
|
||||
},
|
||||
"licensing": {
|
||||
"title": "Licenser för",
|
||||
"subtitle": "Välj den licens som passar dina behov."
|
||||
},
|
||||
"multiTool": {
|
||||
"uploadPdfs": "Ladda upp PDF:er",
|
||||
"upload": "Ladda upp",
|
||||
"addBlankPage": "Lägg till tom sida",
|
||||
"edit": "Redigera:",
|
||||
"undo": "Ångra",
|
||||
"redo": "Gör om",
|
||||
"reset": "Återställ",
|
||||
"selection": "Markering:",
|
||||
"selectAll": "Markera alla",
|
||||
"deselectAll": "Avmarkera alla",
|
||||
"rotate": "Rotera:",
|
||||
"rotateLeft": "Vänster",
|
||||
"rotateRight": "Höger",
|
||||
"transform": "Transformera:",
|
||||
"duplicate": "Duplicera",
|
||||
"split": "Dela",
|
||||
"clear": "Rensa:",
|
||||
"delete": "Ta bort",
|
||||
"download": "Ladda ner:",
|
||||
"downloadSelected": "Ladda ner markerade",
|
||||
"exportPdf": "Exportera PDF",
|
||||
"uploadPdfFiles": "Välj PDF-filer",
|
||||
"dragAndDrop": "Dra och släpp PDF-filer här, eller klicka för att välja",
|
||||
"selectFiles": "Välj filer",
|
||||
"renderingPages": "Renderar sidor...",
|
||||
"actions": {
|
||||
"duplicatePage": "Duplicera denna sida",
|
||||
"deletePage": "Ta bort denna sida",
|
||||
"insertPdf": "Infoga PDF efter denna sida",
|
||||
"toggleSplit": "Växla delning efter denna sida"
|
||||
},
|
||||
"pleaseWait": "Vänligen vänta",
|
||||
"pagesRendering": "Sidor renderas fortfarande. Vänligen vänta...",
|
||||
"noPagesSelected": "Inga sidor markerade",
|
||||
"selectOnePage": "Vänligen välj minst en sida att ladda ner.",
|
||||
"noPages": "Inga sidor",
|
||||
"noPagesToExport": "Det finns inga sidor att exportera.",
|
||||
"renderingTitle": "Renderar sidförhandsvisningar",
|
||||
"errorRendering": "Det gick inte att rendera sidminiatyrer",
|
||||
"error": "Fel",
|
||||
"failedToLoad": "Det gick inte att ladda"
|
||||
},
|
||||
"simpleMode": {
|
||||
"title": "PDF-verktyg",
|
||||
"subtitle": "Välj ett verktyg för att komma igång"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
{
|
||||
"categories": {
|
||||
"popularTools": "Populära verktyg",
|
||||
"editAnnotate": "Redigera & Kommentera",
|
||||
"convertToPdf": "Konvertera till PDF",
|
||||
"convertFromPdf": "Konvertera från PDF",
|
||||
"organizeManage": "Organisera & Hantera",
|
||||
"optimizeRepair": "Optimera & Reparera",
|
||||
"securePdf": "Säkra PDF"
|
||||
},
|
||||
"pdfMultiTool": {
|
||||
"name": "PDF multiverktyg",
|
||||
"subtitle": "Sammanfoga, dela, organisera, ta bort, rotera, lägg till tomma sidor, extrahera och duplicera i ett enhetligt gränssnitt."
|
||||
},
|
||||
"mergePdf": {
|
||||
"name": "Sammanfoga PDF",
|
||||
"subtitle": "Kombinera flera PDF-filer till en fil. Bevarar bokmärken."
|
||||
},
|
||||
"splitPdf": {
|
||||
"name": "Dela PDF",
|
||||
"subtitle": "Extrahera ett intervall av sidor till en ny PDF."
|
||||
},
|
||||
"compressPdf": {
|
||||
"name": "Komprimera PDF",
|
||||
"subtitle": "Minska filstorleken på din PDF.",
|
||||
"algorithmLabel": "Komprimeringsalgoritm",
|
||||
"condense": "Kondensera (Rekommenderas)",
|
||||
"photon": "Foton (För foto-tunga PDF:er)",
|
||||
"condenseInfo": "Kondensera använder avancerad komprimering: tar bort död vikt, optimerar bilder, undergrupperar typsnitt. Bäst för de flesta PDF:er.",
|
||||
"photonInfo": "Foton konverterar sidor till bilder. Använd för foto-tunga/scannade PDF:er.",
|
||||
"photonWarning": "Varning: Text blir icke-valbar och länkarna slutar fungera.",
|
||||
"levelLabel": "Komprimeringsnivå",
|
||||
"light": "Lätt (Bevara kvalitet)",
|
||||
"balanced": "Balanserad (Rekommenderas)",
|
||||
"aggressive": "Aggressiv (Mindre filer)",
|
||||
"extreme": "Extreme (Maximal komprimering)",
|
||||
"grayscale": "Konvertera till gråskala",
|
||||
"grayscaleHint": "Minskar filstorleken genom att ta bort färginformation",
|
||||
"customSettings": "Anpassade inställningar",
|
||||
"customSettingsHint": "Finjustera komprimeringsparametrar:",
|
||||
"outputQuality": "Utdatakvalitet",
|
||||
"resizeImagesTo": "Ändra bildstorlek till",
|
||||
"onlyProcessAbove": "Bearbeta endast över",
|
||||
"removeMetadata": "Ta bort metadata",
|
||||
"subsetFonts": "Undergruppera typsnitt (ta bort oanvända glyfer)",
|
||||
"removeThumbnails": "Ta bort inbäddade miniatyrer",
|
||||
"compressButton": "Komprimera PDF"
|
||||
},
|
||||
"pdfEditor": {
|
||||
"name": "PDF-redigerare",
|
||||
"subtitle": "Kommentera, markera, redigera, kommentera, lägg till former/bilder, sök och visa PDF:er."
|
||||
},
|
||||
"jpgToPdf": {
|
||||
"name": "JPG till PDF",
|
||||
"subtitle": "Skapa en PDF från JPG, JPEG och JPEG2000 (JP2/JPX)-bilder."
|
||||
},
|
||||
"signPdf": {
|
||||
"name": "Signera PDF",
|
||||
"subtitle": "Rita, skriv eller ladda upp din signatur."
|
||||
},
|
||||
"cropPdf": {
|
||||
"name": "Beskär PDF",
|
||||
"subtitle": "Beskär marginalerna på varje sida i din PDF."
|
||||
},
|
||||
"extractPages": {
|
||||
"name": "Extrahera sidor",
|
||||
"subtitle": "Spara ett urval av sidor som nya filer."
|
||||
},
|
||||
"duplicateOrganize": {
|
||||
"name": "Duplicera & organisera",
|
||||
"subtitle": "Duplicera, ordna om och ta bort sidor."
|
||||
},
|
||||
"deletePages": {
|
||||
"name": "Ta bort sidor",
|
||||
"subtitle": "Ta bort specifika sidor från ditt dokument."
|
||||
},
|
||||
"editBookmarks": {
|
||||
"name": "Redigera bokmärken",
|
||||
"subtitle": "Lägg till, redigera, importera, ta bort och extrahera PDF-bokmärken."
|
||||
},
|
||||
"tableOfContents": {
|
||||
"name": "Innehållsförteckning",
|
||||
"subtitle": "Generera en innehållsförteckningssida från PDF-bokmärken."
|
||||
},
|
||||
"pageNumbers": {
|
||||
"name": "Sidnummer",
|
||||
"subtitle": "Infoga sidnummer i ditt dokument."
|
||||
},
|
||||
"batesNumbering": {
|
||||
"name": "Bates-numrering",
|
||||
"subtitle": "Lägg till sekventiella Bates-nummer över en eller flera PDF-filer."
|
||||
},
|
||||
"addWatermark": {
|
||||
"name": "Lägg till vattenstämpel",
|
||||
"subtitle": "Stämpla text eller en bild över dina PDF-sidor.",
|
||||
"applyToAllPages": "Applicera på alla sidor"
|
||||
},
|
||||
"headerFooter": {
|
||||
"name": "Sidhuvud & sidfot",
|
||||
"subtitle": "Lägg till text överst och längst ner på sidor."
|
||||
},
|
||||
"invertColors": {
|
||||
"name": "Invertera färger",
|
||||
"subtitle": "Skapa en \"mörkt läge\"-version av din PDF."
|
||||
},
|
||||
"scannerEffect": {
|
||||
"name": "Skannereffekt",
|
||||
"subtitle": "Få din PDF att se ut som ett scannat dokument.",
|
||||
"scanSettings": "Skannerinställningar",
|
||||
"colorspace": "Färgrymd",
|
||||
"gray": "Grå",
|
||||
"border": "Kant",
|
||||
"rotate": "Rotera",
|
||||
"rotateVariance": "Rotationsvarians",
|
||||
"brightness": "Ljushet",
|
||||
"contrast": "Kontrast",
|
||||
"blur": "Oskärpa",
|
||||
"noise": "Brus",
|
||||
"yellowish": "Gulaktig",
|
||||
"resolution": "Upplösning",
|
||||
"processButton": "Applicera skannereffekt"
|
||||
},
|
||||
"adjustColors": {
|
||||
"name": "Justera färger",
|
||||
"subtitle": "Finjustera ljusstyrka, kontrast, mättnad och mer i din PDF.",
|
||||
"colorSettings": "Färginställningar",
|
||||
"brightness": "Ljushet",
|
||||
"contrast": "Kontrast",
|
||||
"saturation": "Mättnad",
|
||||
"hueShift": "Nyansskift",
|
||||
"temperature": "Temperatur",
|
||||
"tint": "Ton",
|
||||
"gamma": "Gamma",
|
||||
"sepia": "Sepia",
|
||||
"processButton": "Applicera färgjusteringar"
|
||||
},
|
||||
"backgroundColor": {
|
||||
"name": "Bakgrundsfärg",
|
||||
"subtitle": "Ändra bakgrundsfärgen på din PDF."
|
||||
},
|
||||
"changeTextColor": {
|
||||
"name": "Ändra textfärg",
|
||||
"subtitle": "Ändra färgen på text i din PDF."
|
||||
},
|
||||
"addStamps": {
|
||||
"name": "Lägg till stämplar",
|
||||
"subtitle": "Lägg till bildstämplar på din PDF med kommentarsverktygsfältet.",
|
||||
"usernameLabel": "Stämpelanvändarnamn",
|
||||
"usernamePlaceholder": "Ange ditt namn (för stämplar)",
|
||||
"usernameHint": "Detta namn kommer att visas på stämplar du skapar."
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"name": "Ta bort anteckningar",
|
||||
"subtitle": "Ta bort kommentarer, markeringar och länkade."
|
||||
},
|
||||
"pdfFormFiller": {
|
||||
"name": "PDF-formulärifyllare",
|
||||
"subtitle": "Fyll i formulär direkt i webbläsaren. Stöder även XFA-formulär."
|
||||
},
|
||||
"createPdfForm": {
|
||||
"name": "Skapa PDF-formulär",
|
||||
"subtitle": "Skapa ifyllbara PDF-formulär med dra-och-släpp textfält."
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "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",
|
||||
"subtitle": "Konvertera JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX, JP2, PSD, SVG, HEIC, WebP till PDF."
|
||||
},
|
||||
"pngToPdf": {
|
||||
"name": "PNG till PDF",
|
||||
"subtitle": "Skapa en PDF från en eller flera PNG-bilder."
|
||||
},
|
||||
"webpToPdf": {
|
||||
"name": "WebP till PDF",
|
||||
"subtitle": "Skapa en PDF från en eller flera WebP-bilder."
|
||||
},
|
||||
"svgToPdf": {
|
||||
"name": "SVG till PDF",
|
||||
"subtitle": "Skapa en PDF från en eller flera SVG-bilder."
|
||||
},
|
||||
"bmpToPdf": {
|
||||
"name": "BMP till PDF",
|
||||
"subtitle": "Skapa en PDF från en eller flera BMP-bilder."
|
||||
},
|
||||
"heicToPdf": {
|
||||
"name": "HEIC till PDF",
|
||||
"subtitle": "Skapa en PDF från en eller flera HEIC-bilder."
|
||||
},
|
||||
"tiffToPdf": {
|
||||
"name": "TIFF till PDF",
|
||||
"subtitle": "Skapa en PDF från en eller flera TIFF-bilder."
|
||||
},
|
||||
"textToPdf": {
|
||||
"name": "Text till PDF",
|
||||
"subtitle": "Konvertera en vanlig textfil till en PDF."
|
||||
},
|
||||
"jsonToPdf": {
|
||||
"name": "JSON till PDF",
|
||||
"subtitle": "Konvertera JSON-filer till PDF-format."
|
||||
},
|
||||
"pdfToJpg": {
|
||||
"name": "PDF till JPG",
|
||||
"subtitle": "Konvertera varje PDF-sida till en JPG-bild."
|
||||
},
|
||||
"pdfToPng": {
|
||||
"name": "PDF till PNG",
|
||||
"subtitle": "Konvertera varje PDF-sida till en PNG-bild."
|
||||
},
|
||||
"pdfToWebp": {
|
||||
"name": "PDF till WebP",
|
||||
"subtitle": "Konvertera varje PDF-sida till en WebP-bild."
|
||||
},
|
||||
"pdfToBmp": {
|
||||
"name": "PDF till BMP",
|
||||
"subtitle": "Konvertera varje PDF-sida till en BMP-bild."
|
||||
},
|
||||
"pdfToTiff": {
|
||||
"name": "PDF till TIFF",
|
||||
"subtitle": "Konvertera varje PDF-sida till en TIFF-bild."
|
||||
},
|
||||
"pdfToGreyscale": {
|
||||
"name": "PDF till gråskala",
|
||||
"subtitle": "Konvertera alla färger till svart och vitt."
|
||||
},
|
||||
"pdfToJson": {
|
||||
"name": "PDF till JSON",
|
||||
"subtitle": "Konvertera PDF-filer till JSON-format."
|
||||
},
|
||||
"ocrPdf": {
|
||||
"name": "OCR PDF",
|
||||
"subtitle": "Gör en PDF sökbar och kopiaerbar."
|
||||
},
|
||||
"alternateMix": {
|
||||
"name": "Alternera & blanda sidor",
|
||||
"subtitle": "Sammanfoga PDF:er genom att alternera sidor från varje PDF. Bevarar bokmärken."
|
||||
},
|
||||
"addAttachments": {
|
||||
"name": "Lägg till bilagor",
|
||||
"subtitle": "Bädda in en eller flera filer i din PDF."
|
||||
},
|
||||
"extractAttachments": {
|
||||
"name": "Extrahera bilagor",
|
||||
"subtitle": "Extrahera alla inbäddade filer från PDF(er) som en ZIP."
|
||||
},
|
||||
"editAttachments": {
|
||||
"name": "Redigera bilagor",
|
||||
"subtitle": "Visa eller ta bort bilagor i din PDF."
|
||||
},
|
||||
"dividePages": {
|
||||
"name": "Dela sidor",
|
||||
"subtitle": "Dela sidor horisontellt eller vertikalt."
|
||||
},
|
||||
"addBlankPage": {
|
||||
"name": "Lägg till tom sida",
|
||||
"subtitle": "Infoga en tom sida var som helst i din PDF."
|
||||
},
|
||||
"reversePages": {
|
||||
"name": "Omvänd sidor",
|
||||
"subtitle": "Vänd ordningen på alla sidor i ditt dokument."
|
||||
},
|
||||
"rotatePdf": {
|
||||
"name": "Rotera PDF",
|
||||
"subtitle": "Vrid sidor i 90-graderssteg."
|
||||
},
|
||||
"rotateCustom": {
|
||||
"name": "Rotera efter anpassade grader",
|
||||
"subtitle": "Rotera sidor efter valfri vinkel."
|
||||
},
|
||||
"nUpPdf": {
|
||||
"name": "N-Up PDF",
|
||||
"subtitle": "Ordna flera sidor på ett enda ark."
|
||||
},
|
||||
"combineToSinglePage": {
|
||||
"name": "Kombinera till enskild sida",
|
||||
"subtitle": "Sy ihop alla sidor till en kontinuerlig rullning."
|
||||
},
|
||||
"viewMetadata": {
|
||||
"name": "Visa metadata",
|
||||
"subtitle": "Granska de dolda egenskaperna i din PDF."
|
||||
},
|
||||
"editMetadata": {
|
||||
"name": "Redigera metadata",
|
||||
"subtitle": "Ändra författaren, titeln och andra egenskaper."
|
||||
},
|
||||
"pdfsToZip": {
|
||||
"name": "PDF:er till ZIP",
|
||||
"subtitle": "Paketera flera PDF-filer till ett ZIP-arkiv."
|
||||
},
|
||||
"comparePdfs": {
|
||||
"name": "Jämför PDF:er",
|
||||
"subtitle": "Jämför två PDF:er bredvid varandra."
|
||||
},
|
||||
"posterizePdf": {
|
||||
"name": "Postera PDF",
|
||||
"subtitle": "Dela en stor sida i flera mindre sidor."
|
||||
},
|
||||
"fixPageSize": {
|
||||
"name": "Fixa sidstorlek",
|
||||
"subtitle": "Standardisera alla sidor till en enhetlig storlek."
|
||||
},
|
||||
"linearizePdf": {
|
||||
"name": "Linearizera PDF",
|
||||
"subtitle": "Optimera PDF för snabb webbvisning."
|
||||
},
|
||||
"pageDimensions": {
|
||||
"name": "Sidmått",
|
||||
"subtitle": "Analysera sidstorlek, orientering och enheter."
|
||||
},
|
||||
"removeRestrictions": {
|
||||
"name": "Ta bort begränsningar",
|
||||
"subtitle": "Ta bort lösenordsskydd och säkerhetsbegränsningar associerade med digitalt signerade PDF-filer."
|
||||
},
|
||||
"repairPdf": {
|
||||
"name": "Reparera PDF",
|
||||
"subtitle": "Återställ data från skadade eller korrupta PDF-filer."
|
||||
},
|
||||
"encryptPdf": {
|
||||
"name": "Kryptera PDF",
|
||||
"subtitle": "Lås din PDF genom att lägga till ett lösenord."
|
||||
},
|
||||
"sanitizePdf": {
|
||||
"name": "Sanera PDF",
|
||||
"subtitle": "Ta bort metadata, anteckningar, skript och mer."
|
||||
},
|
||||
"decryptPdf": {
|
||||
"name": "Dekryptera PDF",
|
||||
"subtitle": "Lås upp PDF genom att ta bort lösenordsskydd."
|
||||
},
|
||||
"flattenPdf": {
|
||||
"name": "Platta till PDF",
|
||||
"subtitle": "Gör formulärfält och anteckningar icke-redigerbara."
|
||||
},
|
||||
"removeMetadata": {
|
||||
"name": "Ta bort metadata",
|
||||
"subtitle": "Ta bort dold data från din PDF."
|
||||
},
|
||||
"changePermissions": {
|
||||
"name": "Ändra behörigheter",
|
||||
"subtitle": "Ställ in eller ändra användarbehörigheter på en PDF."
|
||||
},
|
||||
"odtToPdf": {
|
||||
"name": "ODT till PDF",
|
||||
"subtitle": "Konvertera OpenDocument Text-filer till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "ODT-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"csvToPdf": {
|
||||
"name": "CSV till PDF",
|
||||
"subtitle": "Konvertera CSV-kalkylarksfiler till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "CSV-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"rtfToPdf": {
|
||||
"name": "RTF till PDF",
|
||||
"subtitle": "Konvertera Rich Text Format-dokument till PDF. Stöder flera filer.",
|
||||
"acceptedFormats": "RTF-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"wordToPdf": {
|
||||
"name": "Word till PDF",
|
||||
"subtitle": "Konvertera Word-dokument (DOCX, DOC, ODT, RTF) till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "DOCX, DOC, ODT, RTF-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"excelToPdf": {
|
||||
"name": "Excel till PDF",
|
||||
"subtitle": "Konvertera Excel-kalkylark (XLSX, XLS, ODS, CSV) till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "XLSX, XLS, ODS, CSV-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"powerpointToPdf": {
|
||||
"name": "PowerPoint till PDF",
|
||||
"subtitle": "Konvertera PowerPoint-presentationer (PPTX, PPT, ODP) till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "PPTX, PPT, ODP-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"markdownToPdf": {
|
||||
"name": "Markdown till PDF",
|
||||
"subtitle": "Skriv eller klistra in Markdown och exportera som en vackert formaterad PDF.",
|
||||
"paneMarkdown": "Markdown",
|
||||
"panePreview": "Förhandsvisning",
|
||||
"btnUpload": "Ladda upp",
|
||||
"btnSyncScroll": "Synkronisera scroll",
|
||||
"btnSettings": "Inställningar",
|
||||
"btnExportPdf": "Exportera PDF",
|
||||
"settingsTitle": "Markdown-inställningar",
|
||||
"settingsPreset": "Förinställning",
|
||||
"presetDefault": "Standard (GFM-liknande)",
|
||||
"presetCommonmark": "CommonMark (strikt)",
|
||||
"presetZero": "Minimal (inga funktioner)",
|
||||
"settingsOptions": "Markdown-alternativ",
|
||||
"optAllowHtml": "Tillåt HTML-taggar",
|
||||
"optBreaks": "Konvertera radbrytningar till <br>",
|
||||
"optLinkify": "Auto-konvertera URL:er till länkade",
|
||||
"optTypographer": "Typograf (smarta citattecken, etc.)"
|
||||
},
|
||||
"pdfBooklet": {
|
||||
"name": "PDF-broschyr",
|
||||
"subtitle": "Ordna om sidor för dubbelsidig broschyutskrift. Vik och häfta för att skapa en broschyr.",
|
||||
"howItWorks": "Så här fungerar det:",
|
||||
"step1": "Ladda upp en PDF-fil.",
|
||||
"step2": "Sidor kommer att ordnas om i broschyrordning.",
|
||||
"step3": "Skriv ut dubbelsidigt, vik på kort kant, vik och häfta.",
|
||||
"paperSize": "Pappersstorlek",
|
||||
"orientation": "Orientering",
|
||||
"portrait": "Stående",
|
||||
"landscape": "Liggande",
|
||||
"pagesPerSheet": "Sidor per ark",
|
||||
"createBooklet": "Skapa broschyr",
|
||||
"processing": "Bearbetar...",
|
||||
"pageCount": "Sidantal kommer att fyllas ut till multipel av 4 om det behövs."
|
||||
},
|
||||
"xpsToPdf": {
|
||||
"name": "XPS till PDF",
|
||||
"subtitle": "Konvertera XPS/OXPS-dokument till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "XPS, OXPS-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"mobiToPdf": {
|
||||
"name": "MOBI till PDF",
|
||||
"subtitle": "Konvertera MOBI e-böcker till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "MOBI-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"epubToPdf": {
|
||||
"name": "EPUB till PDF",
|
||||
"subtitle": "Konvertera EPUB e-böcker till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "EPUB-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"fb2ToPdf": {
|
||||
"name": "FB2 till PDF",
|
||||
"subtitle": "Konvertera FictionBook (FB2) e-böcker till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "FB2-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"cbzToPdf": {
|
||||
"name": "CBZ till PDF",
|
||||
"subtitle": "Konvertera serietidshäften (CBZ/CBR) till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "CBZ, CBR-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"wpdToPdf": {
|
||||
"name": "WPD till PDF",
|
||||
"subtitle": "Konvertera WordPerfect-dokument (WPD) till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "WPD-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"wpsToPdf": {
|
||||
"name": "WPS till PDF",
|
||||
"subtitle": "Konvertera WPS Office-dokument till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "WPS-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"xmlToPdf": {
|
||||
"name": "XML till PDF",
|
||||
"subtitle": "Konvertera XML-dokument till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "XML-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"pagesToPdf": {
|
||||
"name": "Pages till PDF",
|
||||
"subtitle": "Konvertera Apple Pages-dokument till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "Pages-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"odgToPdf": {
|
||||
"name": "ODG till PDF",
|
||||
"subtitle": "Konvertera OpenDocument Graphics (ODG)-filer till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "ODG-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"odsToPdf": {
|
||||
"name": "ODS till PDF",
|
||||
"subtitle": "Konvertera OpenDocument Spreadsheet (ODS)-filer till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "ODS-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"odpToPdf": {
|
||||
"name": "ODP till PDF",
|
||||
"subtitle": "Konvertera OpenDocument Presentation (ODP)-filer till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "ODP-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"pubToPdf": {
|
||||
"name": "PUB till PDF",
|
||||
"subtitle": "Konvertera Microsoft Publisher (PUB)-filer till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "PUB-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"vsdToPdf": {
|
||||
"name": "VSD till PDF",
|
||||
"subtitle": "Konvertera Microsoft Visio (VSD, VSDX)-filer till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "VSD, VSDX-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"psdToPdf": {
|
||||
"name": "PSD till PDF",
|
||||
"subtitle": "Konvertera Adobe Photoshop (PSD)-filer till PDF-format. Stöder flera filer.",
|
||||
"acceptedFormats": "PSD-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"pdfToSvg": {
|
||||
"name": "PDF till SVG",
|
||||
"subtitle": "Konvertera varje sida i en PDF till en skalbar vektorgrafik (SVG) för perfekt kvalitet i valfri storlek."
|
||||
},
|
||||
"extractTables": {
|
||||
"name": "Extrahera PDF-tabeller",
|
||||
"subtitle": "Extrahera tabeller från PDF-filer och exportera som CSV, JSON eller Markdown."
|
||||
},
|
||||
"pdfToCsv": {
|
||||
"name": "PDF till CSV",
|
||||
"subtitle": "Extrahera tabeller från PDF och konvertera till CSV-format."
|
||||
},
|
||||
"pdfToExcel": {
|
||||
"name": "PDF till Excel",
|
||||
"subtitle": "Extrahera tabeller från PDF och konvertera till Excel (XLSX)-format."
|
||||
},
|
||||
"pdfToText": {
|
||||
"name": "PDF till text",
|
||||
"subtitle": "Extrahera text från PDF-filer och spara som vanlig text (.txt). Stöder flera filer.",
|
||||
"note": "Detta verktyg fungerar ENDAST med digitalt skapade PDF:er. För scannade dokument eller bildbaserade PDF:er, använd vårt OCR PDF-verktyg istället.",
|
||||
"convertButton": "Extrahera text"
|
||||
},
|
||||
"digitalSignPdf": {
|
||||
"name": "Digital signatur PDF",
|
||||
"pageTitle": "Digital signatur PDF - Lägg till kryptografisk signatur | BentoPDF",
|
||||
"subtitle": "Lägg till en kryptografisk digital signatur på din PDF med X.509-certifikat. Stöder PKCS#12 (.pfx, .p12) och PEM-format. Din privata nyckel lämnar aldrig din webbläsare.",
|
||||
"certificateSection": "Certifikat",
|
||||
"uploadCert": "Ladda upp certifikat (.pfx, .p12)",
|
||||
"certPassword": "Certifikatlösenord",
|
||||
"certPasswordPlaceholder": "Ange certifikatlösenord",
|
||||
"certInfo": "Certifikatinformation",
|
||||
"certSubject": "Ämne",
|
||||
"certIssuer": "Utfärdare",
|
||||
"certValidity": "Giltig",
|
||||
"signatureDetails": "Signaturdetaljer (Valfritt)",
|
||||
"reason": "Anledning",
|
||||
"reasonPlaceholder": "t.ex. Jag godkänner detta dokument",
|
||||
"location": "Plats",
|
||||
"locationPlaceholder": "t.ex. Stockholm, Sverige",
|
||||
"contactInfo": "Kontaktinformation",
|
||||
"contactPlaceholder": "t.ex. [email protected]",
|
||||
"applySignature": "Applicera digital signatur",
|
||||
"successMessage": "PDF signerad framgångsrikt! Signaturen kan verifieras i vilken PDF-läsare som helst."
|
||||
},
|
||||
"validateSignaturePdf": {
|
||||
"name": "Validera PDF-signatur",
|
||||
"pageTitle": "Validera PDF-signatur - Verifiera digitala signaturer | BentoPDF",
|
||||
"subtitle": "Verifiera digitala signaturer i dina PDF-filer. Kontrollera certifikatgiltighet, visa signerardetaljer och bekräfta dokumentintegritet. All bearbetning sker i din webbläsare."
|
||||
},
|
||||
"emailToPdf": {
|
||||
"name": "E-post till PDF",
|
||||
"subtitle": "Konvertera e-postfiler (EML, MSG) till PDF-format. Stöder Outlook-exporter och standard e-postformat.",
|
||||
"acceptedFormats": "EML, MSG-filer",
|
||||
"convertButton": "Konvertera till PDF"
|
||||
},
|
||||
"fontToOutline": {
|
||||
"name": "Typsnitt till kontur",
|
||||
"subtitle": "Konvertera alla typsnitt till vektorkonturer för konsekvent rendering på alla enheter."
|
||||
},
|
||||
"deskewPdf": {
|
||||
"name": "Räta upp PDF",
|
||||
"subtitle": "Automatiskt räta upp snedvinklade scannade sidor med OpenCV."
|
||||
},
|
||||
"pdfToWord": {
|
||||
"name": "PDF till Word",
|
||||
"subtitle": "Konvertera PDF-filer till redigerbara Word-dokument."
|
||||
},
|
||||
"extractImages": {
|
||||
"name": "Extrahera bilder",
|
||||
"subtitle": "Extrahera alla inbäddade bilder från dina PDF-filer."
|
||||
},
|
||||
"pdfToMarkdown": {
|
||||
"name": "PDF till Markdown",
|
||||
"subtitle": "Konvertera PDF-text och tabeller till Markdown-format."
|
||||
},
|
||||
"preparePdfForAi": {
|
||||
"name": "Förbered PDF för AI",
|
||||
"subtitle": "Extrahera PDF-innehåll som LlamaIndex JSON för RAG/LLM-pipelines."
|
||||
},
|
||||
"pdfOcg": {
|
||||
"name": "PDF OCG",
|
||||
"subtitle": "Visa, växla, lägg till och ta bort OCG-lager i din PDF."
|
||||
},
|
||||
"pdfToPdfa": {
|
||||
"name": "PDF till PDF/A",
|
||||
"subtitle": "Konvertera PDF till PDF/A för långtidsarkivering."
|
||||
},
|
||||
"rasterizePdf": {
|
||||
"name": "Rasterisera PDF",
|
||||
"subtitle": "Konvertera PDF till bildbaserad PDF. Platta till lager och ta bort valbar text."
|
||||
},
|
||||
"pdfWorkflow": {
|
||||
"name": "PDF-arbetsflödesbyggare",
|
||||
"subtitle": "Bygg anpassade PDF-bearbetningspipelines med en visuell nodredigerare.",
|
||||
"nodes": "Noder",
|
||||
"searchNodes": "Sök noder...",
|
||||
"run": "Kör",
|
||||
"clear": "Rensa",
|
||||
"save": "Spara",
|
||||
"load": "Ladda",
|
||||
"export": "Exportera",
|
||||
"import": "Importera",
|
||||
"ready": "Redo",
|
||||
"settings": "Inställningar",
|
||||
"processing": "Bearbetar...",
|
||||
"saveTemplate": "Spara mall",
|
||||
"templateName": "Mallnamn",
|
||||
"templatePlaceholder": "t.ex. Faktura-arbetsflöde",
|
||||
"cancel": "Avbryt",
|
||||
"loadTemplate": "Ladda mall",
|
||||
"noTemplates": "Inga sparade mallar ännu.",
|
||||
"ok": "OK",
|
||||
"workflowCompleted": "Arbetsflöde klart",
|
||||
"errorDuringExecution": "Fel under körning",
|
||||
"addNodeError": "Lägg till minst en nod för att köra arbetsflödet.",
|
||||
"needInputOutput": "Ditt arbetsflöde behöver minst en inmatningsnod och en utmatningsnod för att köra.",
|
||||
"enterName": "Vänligen ange ett namn.",
|
||||
"templateExists": "En mall med detta namn finns redan.",
|
||||
"templateSaved": "Mallen \"{{name}}\" sparad.",
|
||||
"templateLoaded": "Mallen \"{{name}}\" laddad.",
|
||||
"failedLoadTemplate": "Det gick inte att ladda mallen.",
|
||||
"noSettings": "Inga konfigurerbara inställningar för denna nod.",
|
||||
"advancedSettings": "Avancerade inställningar"
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,29 @@
|
||||
},
|
||||
"compressPdf": {
|
||||
"name": "PDF Sıkıştır",
|
||||
"subtitle": "PDF dosya boyutunu küçültün."
|
||||
"subtitle": "PDF dosya boyutunu küçültün.",
|
||||
"algorithmLabel": "Sıkıştırma Algoritması",
|
||||
"condense": "Condense (Önerilen)",
|
||||
"photon": "Photon (Fotoğraf Ağırlıklı PDF'ler İçin)",
|
||||
"condenseInfo": "Condense gelişmiş sıkıştırma kullanır: gereksiz verileri kaldırır, görselleri optimize eder, fontları küçültür. Çoğu PDF için idealdir.",
|
||||
"photonInfo": "Photon sayfaları görsellere dönüştürür. Fotoğraf ağırlıklı/taranmış PDF'ler için kullanın.",
|
||||
"photonWarning": "Uyarı: Metin seçilemez hale gelecek ve bağlantılar çalışmayacak.",
|
||||
"levelLabel": "Sıkıştırma Seviyesi",
|
||||
"light": "Hafif (Kaliteyi Koru)",
|
||||
"balanced": "Dengeli (Önerilen)",
|
||||
"aggressive": "Agresif (Daha Küçük Dosyalar)",
|
||||
"extreme": "Aşırı (Maksimum Sıkıştırma)",
|
||||
"grayscale": "Gri Tonlamaya Dönüştür",
|
||||
"grayscaleHint": "Renk bilgisini kaldırarak dosya boyutunu küçültür",
|
||||
"customSettings": "Özel Ayarlar",
|
||||
"customSettingsHint": "Sıkıştırma parametrelerini ince ayarlayın:",
|
||||
"outputQuality": "Çıktı Kalitesi",
|
||||
"resizeImagesTo": "Görselleri Şu Boyuta Dönüştür",
|
||||
"onlyProcessAbove": "Yalnızca Şunun Üzerini İşle",
|
||||
"removeMetadata": "Meta verileri kaldır",
|
||||
"subsetFonts": "Font alt kümesi oluştur (kullanılmayan glifleri kaldır)",
|
||||
"removeThumbnails": "Gömülü küçük resimleri kaldır",
|
||||
"compressButton": "PDF'yi Sıkıştır"
|
||||
},
|
||||
"pdfEditor": {
|
||||
"name": "PDF Düzenleyici",
|
||||
@@ -141,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",
|
||||
|
||||
@@ -22,7 +22,29 @@
|
||||
},
|
||||
"compressPdf": {
|
||||
"name": "壓縮 PDF",
|
||||
"subtitle": "降低你的 PDF 檔案大小。"
|
||||
"subtitle": "降低你的 PDF 檔案大小。",
|
||||
"algorithmLabel": "壓縮演算法",
|
||||
"condense": "Condense(推薦)",
|
||||
"photon": "Photon(適用於圖片較多的 PDF)",
|
||||
"condenseInfo": "Condense 使用高級壓縮:移除冗餘資料、優化圖片、精簡字型。適用於大多數 PDF。",
|
||||
"photonInfo": "Photon 將頁面轉換為圖片。適用於圖片較多/掃描的 PDF。",
|
||||
"photonWarning": "警告:文字將無法選取,連結將失效。",
|
||||
"levelLabel": "壓縮等級",
|
||||
"light": "輕度(保持品質)",
|
||||
"balanced": "平衡(推薦)",
|
||||
"aggressive": "積極(更小檔案)",
|
||||
"extreme": "極限(最大壓縮)",
|
||||
"grayscale": "轉換為灰階",
|
||||
"grayscaleHint": "透過移除色彩資訊來縮小檔案大小",
|
||||
"customSettings": "自訂設定",
|
||||
"customSettingsHint": "微調壓縮參數:",
|
||||
"outputQuality": "輸出品質",
|
||||
"resizeImagesTo": "調整圖片至",
|
||||
"onlyProcessAbove": "僅處理高於",
|
||||
"removeMetadata": "移除中繼資料",
|
||||
"subsetFonts": "精簡字型(移除未使用的字符)",
|
||||
"removeThumbnails": "移除嵌入的縮圖",
|
||||
"compressButton": "壓縮 PDF"
|
||||
},
|
||||
"pdfEditor": {
|
||||
"name": "PDF 編輯器",
|
||||
@@ -141,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "移除空白頁面",
|
||||
"subtitle": "自動偵測並刪除空白頁面。"
|
||||
"subtitle": "自動偵測並刪除空白頁面。",
|
||||
"sensitivityHint": "越高 = 越嚴格,僅偵測純空白頁面。越低 = 允許包含少量內容的頁面。"
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "圖片轉 PDF",
|
||||
|
||||
@@ -144,7 +144,10 @@
|
||||
},
|
||||
"addStamps": {
|
||||
"name": "添加印章",
|
||||
"subtitle": "使用注释工具栏向您的 PDF 添加图片印章。"
|
||||
"subtitle": "使用注释工具栏向您的 PDF 添加图片印章。",
|
||||
"usernameLabel": "印章用户名",
|
||||
"usernamePlaceholder": "输入您的姓名(用于印章)",
|
||||
"usernameHint": "此名称将显示在您创建的印章上。"
|
||||
},
|
||||
"removeAnnotations": {
|
||||
"name": "移除注释",
|
||||
@@ -160,7 +163,8 @@
|
||||
},
|
||||
"removeBlankPages": {
|
||||
"name": "移除空白页",
|
||||
"subtitle": "自动检测并删除空白页。"
|
||||
"subtitle": "自动检测并删除空白页。",
|
||||
"sensitivityHint": "越高 = 越严格,仅检测纯空白页。越低 = 允许包含少量内容的页面。"
|
||||
},
|
||||
"imageToPdf": {
|
||||
"name": "图片转 PDF",
|
||||
|
||||
@@ -335,6 +335,30 @@
|
||||
"created_at": "2026-02-25T17:50:30Z",
|
||||
"repoId": 1074785178,
|
||||
"pullRequestNo": 516
|
||||
},
|
||||
{
|
||||
"name": "maxbengtzen",
|
||||
"id": 149163078,
|
||||
"comment_id": 3972875117,
|
||||
"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),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -18,6 +18,7 @@ export const supportedLanguages = [
|
||||
'pt',
|
||||
'nl',
|
||||
'da',
|
||||
'sv',
|
||||
] as const;
|
||||
export type SupportedLanguage = (typeof supportedLanguages)[number];
|
||||
|
||||
@@ -37,6 +38,7 @@ export const languageNames: Record<SupportedLanguage, string> = {
|
||||
pt: 'Português',
|
||||
nl: 'Nederlands',
|
||||
da: 'Dansk',
|
||||
sv: 'Svenska',
|
||||
};
|
||||
|
||||
export const getLanguageFromUrl = (): SupportedLanguage => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
addImageWatermark,
|
||||
parsePageRange,
|
||||
} from '../utils/pdf-operations.js';
|
||||
import { AddWatermarkState } from '@/types';
|
||||
import { AddWatermarkState, PageWatermarkConfig } from '@/types';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
@@ -20,22 +20,6 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
import.meta.url
|
||||
).toString();
|
||||
|
||||
interface PageWatermarkConfig {
|
||||
type: 'text' | 'image';
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
color: string;
|
||||
opacityText: number;
|
||||
angleText: number;
|
||||
imageDataUrl: string | null;
|
||||
imageFile: File | null;
|
||||
imageScale: number;
|
||||
opacityImage: number;
|
||||
angleImage: number;
|
||||
}
|
||||
|
||||
const pageState: AddWatermarkState = {
|
||||
file: null,
|
||||
pdfDoc: null,
|
||||
|
||||
+284
-162
@@ -1,236 +1,358 @@
|
||||
import { showAlert } from '../ui.js';
|
||||
import { downloadFile, formatBytes, initializeQpdf, readFileAsArrayBuffer } from '../utils/helpers.js';
|
||||
import {
|
||||
downloadFile,
|
||||
formatBytes,
|
||||
initializeQpdf,
|
||||
readFileAsArrayBuffer,
|
||||
} from '../utils/helpers.js';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import JSZip from 'jszip';
|
||||
import { DecryptPdfState } from '@/types';
|
||||
|
||||
const pageState: DecryptPdfState = {
|
||||
file: null,
|
||||
files: [],
|
||||
};
|
||||
|
||||
function resetState() {
|
||||
pageState.file = null;
|
||||
pageState.files = [];
|
||||
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
const fileControls = document.getElementById('file-controls');
|
||||
if (fileControls) fileControls.classList.add('hidden');
|
||||
|
||||
const passwordInput = document.getElementById('password-input') as HTMLInputElement;
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
const passwordInput = document.getElementById(
|
||||
'password-input'
|
||||
) as HTMLInputElement;
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
}
|
||||
|
||||
async function updateUI() {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
const fileControls = document.getElementById('file-controls');
|
||||
|
||||
if (!fileDisplayArea) return;
|
||||
if (!fileDisplayArea) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
if (pageState.file) {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
if (pageState.files.length > 0) {
|
||||
pageState.files.forEach((file, index) => {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = file.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = formatBytes(pageState.file.size);
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = formatBytes(file.size);
|
||||
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = function () {
|
||||
resetState();
|
||||
};
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className =
|
||||
'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = function () {
|
||||
pageState.files.splice(index, 1);
|
||||
updateUI();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
});
|
||||
|
||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||
} else {
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
}
|
||||
createIcons({ icons });
|
||||
|
||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||
if (fileControls) fileControls.classList.remove('hidden');
|
||||
} else {
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
if (fileControls) fileControls.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(files: FileList | null) {
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pageState.file = file;
|
||||
updateUI();
|
||||
}
|
||||
if (files && files.length > 0) {
|
||||
const pdfFiles = Array.from(files).filter(
|
||||
(f) =>
|
||||
f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
if (pdfFiles.length > 0) {
|
||||
pageState.files.push(...pdfFiles);
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function decryptPdf() {
|
||||
if (!pageState.file) {
|
||||
showAlert('No File', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
if (pageState.files.length === 0) {
|
||||
showAlert('No File', 'Please upload at least one PDF file.');
|
||||
return;
|
||||
}
|
||||
|
||||
const password = (document.getElementById('password-input') as HTMLInputElement)?.value;
|
||||
const password = (
|
||||
document.getElementById('password-input') as HTMLInputElement
|
||||
)?.value;
|
||||
|
||||
if (!password) {
|
||||
showAlert('Input Required', 'Please enter the PDF password.');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
showAlert('Input Required', 'Please enter the PDF password.');
|
||||
return;
|
||||
}
|
||||
|
||||
const inputPath = '/input.pdf';
|
||||
const outputPath = '/output.pdf';
|
||||
let qpdf: any;
|
||||
const loaderModal = document.getElementById('loader-modal');
|
||||
const loaderText = document.getElementById('loader-text');
|
||||
let qpdf: any;
|
||||
|
||||
const loaderModal = document.getElementById('loader-modal');
|
||||
const loaderText = document.getElementById('loader-text');
|
||||
try {
|
||||
if (loaderModal) loaderModal.classList.remove('hidden');
|
||||
if (loaderText) loaderText.textContent = 'Initializing decryption...';
|
||||
|
||||
try {
|
||||
if (loaderModal) loaderModal.classList.remove('hidden');
|
||||
if (loaderText) loaderText.textContent = 'Initializing decryption...';
|
||||
qpdf = await initializeQpdf();
|
||||
|
||||
qpdf = await initializeQpdf();
|
||||
if (pageState.files.length === 1) {
|
||||
// Single file: decrypt and download directly
|
||||
const file = pageState.files[0];
|
||||
const inputPath = '/input.pdf';
|
||||
const outputPath = '/output.pdf';
|
||||
|
||||
try {
|
||||
if (loaderText) loaderText.textContent = 'Reading encrypted PDF...';
|
||||
const fileBuffer = await readFileAsArrayBuffer(pageState.file);
|
||||
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
||||
|
||||
qpdf.FS.writeFile(inputPath, uint8Array);
|
||||
|
||||
if (loaderText) loaderText.textContent = 'Decrypting PDF...';
|
||||
|
||||
const args = [inputPath, '--password=' + password, '--decrypt', outputPath];
|
||||
const args = [
|
||||
inputPath,
|
||||
'--password=' + password,
|
||||
'--decrypt',
|
||||
outputPath,
|
||||
];
|
||||
|
||||
try {
|
||||
qpdf.callMain(args);
|
||||
qpdf.callMain(args);
|
||||
} catch (qpdfError: any) {
|
||||
console.error('qpdf execution error:', qpdfError);
|
||||
|
||||
if (
|
||||
qpdfError.message?.includes('invalid password') ||
|
||||
qpdfError.message?.includes('password')
|
||||
) {
|
||||
throw new Error('INVALID_PASSWORD');
|
||||
}
|
||||
throw qpdfError;
|
||||
if (
|
||||
qpdfError.message?.includes('invalid password') ||
|
||||
qpdfError.message?.includes('password')
|
||||
) {
|
||||
throw new Error('INVALID_PASSWORD');
|
||||
}
|
||||
throw qpdfError;
|
||||
}
|
||||
|
||||
if (loaderText) loaderText.textContent = 'Preparing download...';
|
||||
const outputFile = qpdf.FS.readFile(outputPath, { encoding: 'binary' });
|
||||
|
||||
if (outputFile.length === 0) {
|
||||
throw new Error('Decryption resulted in an empty file.');
|
||||
throw new Error('Decryption resulted in an empty file.');
|
||||
}
|
||||
|
||||
const blob = new Blob([outputFile], { type: 'application/pdf' });
|
||||
downloadFile(blob, `unlocked-${pageState.file.name}`);
|
||||
const blob = new Blob([new Uint8Array(outputFile)], {
|
||||
type: 'application/pdf',
|
||||
});
|
||||
downloadFile(blob, `unlocked-${file.name}`);
|
||||
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
showAlert(
|
||||
'Success',
|
||||
'PDF decrypted successfully! Your download has started.',
|
||||
'success',
|
||||
() => { resetState(); }
|
||||
'Success',
|
||||
'PDF decrypted successfully! Your download has started.',
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error('Error during PDF decryption:', error);
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
|
||||
if (error.message === 'INVALID_PASSWORD') {
|
||||
showAlert(
|
||||
'Incorrect Password',
|
||||
'The password you entered is incorrect. Please try again.'
|
||||
);
|
||||
} else if (error.message?.includes('password')) {
|
||||
showAlert(
|
||||
'Password Error',
|
||||
'Unable to decrypt the PDF with the provided password.'
|
||||
);
|
||||
} else {
|
||||
showAlert(
|
||||
'Decryption Failed',
|
||||
`An error occurred: ${error.message || 'The password you entered is wrong or the file is corrupted.'}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
} finally {
|
||||
try {
|
||||
if (qpdf?.FS) {
|
||||
try {
|
||||
qpdf.FS.unlink(inputPath);
|
||||
} catch (e) {
|
||||
console.warn('Failed to unlink input file:', e);
|
||||
}
|
||||
try {
|
||||
qpdf.FS.unlink(outputPath);
|
||||
} catch (e) {
|
||||
console.warn('Failed to unlink output file:', e);
|
||||
}
|
||||
}
|
||||
if (qpdf?.FS) {
|
||||
if (qpdf.FS.analyzePath(inputPath).exists)
|
||||
qpdf.FS.unlink(inputPath);
|
||||
if (qpdf.FS.analyzePath(outputPath).exists)
|
||||
qpdf.FS.unlink(outputPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to cleanup WASM FS:', cleanupError);
|
||||
console.warn('Failed to cleanup WASM FS:', cleanupError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multiple files: decrypt all and download as ZIP
|
||||
const zip = new JSZip();
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (let i = 0; i < pageState.files.length; i++) {
|
||||
const file = pageState.files[i];
|
||||
const inputPath = `/input_${i}.pdf`;
|
||||
const outputPath = `/output_${i}.pdf`;
|
||||
|
||||
if (loaderText)
|
||||
loaderText.textContent = `Decrypting ${file.name} (${i + 1}/${pageState.files.length})...`;
|
||||
|
||||
try {
|
||||
const fileBuffer = await readFileAsArrayBuffer(file);
|
||||
const uint8Array = new Uint8Array(fileBuffer as ArrayBuffer);
|
||||
qpdf.FS.writeFile(inputPath, uint8Array);
|
||||
|
||||
const args = [
|
||||
inputPath,
|
||||
'--password=' + password,
|
||||
'--decrypt',
|
||||
outputPath,
|
||||
];
|
||||
|
||||
try {
|
||||
qpdf.callMain(args);
|
||||
} catch (qpdfError: any) {
|
||||
if (
|
||||
qpdfError.message?.includes('invalid password') ||
|
||||
qpdfError.message?.includes('password')
|
||||
) {
|
||||
throw new Error(`Invalid password for ${file.name}`);
|
||||
}
|
||||
throw qpdfError;
|
||||
}
|
||||
|
||||
const outputFile = qpdf.FS.readFile(outputPath, {
|
||||
encoding: 'binary',
|
||||
});
|
||||
if (!outputFile || outputFile.length === 0) {
|
||||
throw new Error(
|
||||
`Decryption resulted in an empty file for ${file.name}.`
|
||||
);
|
||||
}
|
||||
|
||||
zip.file(`unlocked-${file.name}`, outputFile, { binary: true });
|
||||
successCount++;
|
||||
} catch (fileError: any) {
|
||||
errorCount++;
|
||||
console.error(`Failed to decrypt ${file.name}:`, fileError);
|
||||
} finally {
|
||||
try {
|
||||
if (qpdf?.FS) {
|
||||
if (qpdf.FS.analyzePath(inputPath).exists)
|
||||
qpdf.FS.unlink(inputPath);
|
||||
if (qpdf.FS.analyzePath(outputPath).exists)
|
||||
qpdf.FS.unlink(outputPath);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
`Failed to cleanup WASM FS for ${file.name}:`,
|
||||
cleanupError
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount === 0) {
|
||||
throw new Error(
|
||||
'No PDF files could be decrypted. The password may be incorrect.'
|
||||
);
|
||||
}
|
||||
|
||||
if (loaderText) loaderText.textContent = 'Generating ZIP file...';
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
downloadFile(zipBlob, 'decrypted-pdfs.zip');
|
||||
|
||||
let alertMessage = `${successCount} PDF(s) decrypted successfully.`;
|
||||
if (errorCount > 0) {
|
||||
alertMessage += ` ${errorCount} file(s) failed.`;
|
||||
}
|
||||
showAlert('Processing Complete', alertMessage, 'success', () => {
|
||||
resetState();
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error during PDF decryption:', error);
|
||||
|
||||
if (error.message === 'INVALID_PASSWORD') {
|
||||
showAlert(
|
||||
'Incorrect Password',
|
||||
'The password you entered is incorrect. Please try again.'
|
||||
);
|
||||
} else if (error.message?.includes('password')) {
|
||||
showAlert(
|
||||
'Password Error',
|
||||
'Unable to decrypt the PDF with the provided password.'
|
||||
);
|
||||
} else {
|
||||
showAlert(
|
||||
'Decryption Failed',
|
||||
`An error occurred: ${error.message || 'The password you entered is wrong or the file is corrupted.'}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const addMoreBtn = document.getElementById('add-more-btn');
|
||||
const clearFilesBtn = document.getElementById('clear-files-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function () {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function () {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', function (e) {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', function (e) {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const pdfFiles = Array.from(files).filter(function (f) {
|
||||
return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
|
||||
});
|
||||
if (pdfFiles.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(pdfFiles[0]);
|
||||
handleFileSelect(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
});
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
handleFileSelect(e.dataTransfer?.files);
|
||||
});
|
||||
|
||||
fileInput.addEventListener('click', function () {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
fileInput.addEventListener('click', function () {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', decryptPdf);
|
||||
}
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', decryptPdf);
|
||||
}
|
||||
|
||||
if (addMoreBtn) {
|
||||
addMoreBtn.addEventListener('click', function () {
|
||||
fileInput.value = '';
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (clearFilesBtn) {
|
||||
clearFilesBtn.addEventListener('click', function () {
|
||||
resetState();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
+172
-184
@@ -1,230 +1,218 @@
|
||||
import { showAlert } from '../ui.js';
|
||||
import { downloadFile, formatBytes, hexToRgb } from '../utils/helpers.js';
|
||||
import { PDFDocument as PDFLibDocument, rgb, PageSizes } from 'pdf-lib';
|
||||
import { fixPageSize as fixPageSizeCore } from '../utils/pdf-operations';
|
||||
import { icons, createIcons } from 'lucide';
|
||||
import { FixPageSizeState } from '@/types';
|
||||
|
||||
const pageState: FixPageSizeState = {
|
||||
file: null,
|
||||
file: null,
|
||||
};
|
||||
|
||||
function resetState() {
|
||||
pageState.file = null;
|
||||
pageState.file = null;
|
||||
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
|
||||
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
async function updateUI() {
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
const fileDisplayArea = document.getElementById('file-display-area');
|
||||
const toolOptions = document.getElementById('tool-options');
|
||||
|
||||
if (!fileDisplayArea) return;
|
||||
if (!fileDisplayArea) return;
|
||||
|
||||
fileDisplayArea.innerHTML = '';
|
||||
fileDisplayArea.innerHTML = '';
|
||||
|
||||
if (pageState.file) {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className = 'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
if (pageState.file) {
|
||||
const fileDiv = document.createElement('div');
|
||||
fileDiv.className =
|
||||
'flex items-center justify-between bg-gray-700 p-3 rounded-lg text-sm';
|
||||
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
const infoContainer = document.createElement('div');
|
||||
infoContainer.className = 'flex flex-col overflow-hidden';
|
||||
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
const nameSpan = document.createElement('div');
|
||||
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
|
||||
nameSpan.textContent = pageState.file.name;
|
||||
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = formatBytes(pageState.file.size);
|
||||
const metaSpan = document.createElement('div');
|
||||
metaSpan.className = 'text-xs text-gray-400';
|
||||
metaSpan.textContent = formatBytes(pageState.file.size);
|
||||
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
infoContainer.append(nameSpan, metaSpan);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = function () {
|
||||
resetState();
|
||||
};
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
|
||||
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
|
||||
removeBtn.onclick = function () {
|
||||
resetState();
|
||||
};
|
||||
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
fileDiv.append(infoContainer, removeBtn);
|
||||
fileDisplayArea.appendChild(fileDiv);
|
||||
createIcons({ icons });
|
||||
|
||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||
} else {
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
}
|
||||
if (toolOptions) toolOptions.classList.remove('hidden');
|
||||
} else {
|
||||
if (toolOptions) toolOptions.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(files: FileList | null) {
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) {
|
||||
pageState.file = file;
|
||||
updateUI();
|
||||
}
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (
|
||||
file.type === 'application/pdf' ||
|
||||
file.name.toLowerCase().endsWith('.pdf')
|
||||
) {
|
||||
pageState.file = file;
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fixPageSize() {
|
||||
if (!pageState.file) {
|
||||
showAlert('No File', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
if (!pageState.file) {
|
||||
showAlert('No File', 'Please upload a PDF file first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSizeKey = (document.getElementById('target-size') as HTMLSelectElement).value;
|
||||
const orientation = (document.getElementById('orientation') as HTMLSelectElement).value;
|
||||
const scalingMode = (document.querySelector('input[name="scaling-mode"]:checked') as HTMLInputElement).value;
|
||||
const backgroundColor = hexToRgb((document.getElementById('background-color') as HTMLInputElement).value);
|
||||
const targetSize = (
|
||||
document.getElementById('target-size') as HTMLSelectElement
|
||||
).value;
|
||||
const orientation = (
|
||||
document.getElementById('orientation') as HTMLSelectElement
|
||||
).value;
|
||||
const scalingMode = (
|
||||
document.querySelector(
|
||||
'input[name="scaling-mode"]:checked'
|
||||
) as HTMLInputElement
|
||||
).value;
|
||||
const backgroundColor = hexToRgb(
|
||||
(document.getElementById('background-color') as HTMLInputElement).value
|
||||
);
|
||||
|
||||
const loaderModal = document.getElementById('loader-modal');
|
||||
const loaderText = document.getElementById('loader-text');
|
||||
if (loaderModal) loaderModal.classList.remove('hidden');
|
||||
if (loaderText) loaderText.textContent = 'Standardizing pages...';
|
||||
const loaderModal = document.getElementById('loader-modal');
|
||||
const loaderText = document.getElementById('loader-text');
|
||||
if (loaderModal) loaderModal.classList.remove('hidden');
|
||||
if (loaderText) loaderText.textContent = 'Standardizing pages...';
|
||||
|
||||
try {
|
||||
let targetWidth, targetHeight;
|
||||
try {
|
||||
const customWidth =
|
||||
parseFloat(
|
||||
(document.getElementById('custom-width') as HTMLInputElement)?.value
|
||||
) || 210;
|
||||
const customHeight =
|
||||
parseFloat(
|
||||
(document.getElementById('custom-height') as HTMLInputElement)?.value
|
||||
) || 297;
|
||||
const customUnits =
|
||||
(document.getElementById('custom-units') as HTMLSelectElement)?.value ||
|
||||
'mm';
|
||||
|
||||
if (targetSizeKey === 'Custom') {
|
||||
const width = parseFloat((document.getElementById('custom-width') as HTMLInputElement).value);
|
||||
const height = parseFloat((document.getElementById('custom-height') as HTMLInputElement).value);
|
||||
const units = (document.getElementById('custom-units') as HTMLSelectElement).value;
|
||||
const arrayBuffer = await pageState.file.arrayBuffer();
|
||||
const pdfBytes = new Uint8Array(arrayBuffer);
|
||||
|
||||
if (units === 'in') {
|
||||
targetWidth = width * 72;
|
||||
targetHeight = height * 72;
|
||||
} else {
|
||||
// mm
|
||||
targetWidth = width * (72 / 25.4);
|
||||
targetHeight = height * (72 / 25.4);
|
||||
}
|
||||
} else {
|
||||
[targetWidth, targetHeight] = PageSizes[targetSizeKey as keyof typeof PageSizes];
|
||||
}
|
||||
const newPdfBytes = await fixPageSizeCore(pdfBytes, {
|
||||
targetSize,
|
||||
orientation,
|
||||
scalingMode,
|
||||
backgroundColor,
|
||||
customWidth,
|
||||
customHeight,
|
||||
customUnits,
|
||||
});
|
||||
|
||||
if (orientation === 'landscape' && targetWidth < targetHeight) {
|
||||
[targetWidth, targetHeight] = [targetHeight, targetWidth];
|
||||
} else if (orientation === 'portrait' && targetWidth > targetHeight) {
|
||||
[targetWidth, targetHeight] = [targetHeight, targetWidth];
|
||||
}
|
||||
|
||||
const arrayBuffer = await pageState.file.arrayBuffer();
|
||||
const sourceDoc = await PDFLibDocument.load(arrayBuffer);
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
|
||||
for (const sourcePage of sourceDoc.getPages()) {
|
||||
const { width: sourceWidth, height: sourceHeight } = sourcePage.getSize();
|
||||
const embeddedPage = await newDoc.embedPage(sourcePage);
|
||||
|
||||
const newPage = newDoc.addPage([targetWidth, targetHeight]);
|
||||
newPage.drawRectangle({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
color: rgb(backgroundColor.r, backgroundColor.g, backgroundColor.b),
|
||||
});
|
||||
|
||||
const scaleX = targetWidth / sourceWidth;
|
||||
const scaleY = targetHeight / sourceHeight;
|
||||
const scale = scalingMode === 'fit' ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
|
||||
|
||||
const scaledWidth = sourceWidth * scale;
|
||||
const scaledHeight = sourceHeight * scale;
|
||||
|
||||
const x = (targetWidth - scaledWidth) / 2;
|
||||
const y = (targetHeight - scaledHeight) / 2;
|
||||
|
||||
newPage.drawPage(embeddedPage, {
|
||||
x,
|
||||
y,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
});
|
||||
}
|
||||
|
||||
const newPdfBytes = await newDoc.save();
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'standardized.pdf'
|
||||
);
|
||||
showAlert('Success', 'Page sizes standardized successfully!', 'success', () => { resetState(); });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'An error occurred while standardizing pages.');
|
||||
} finally {
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
}
|
||||
downloadFile(
|
||||
new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }),
|
||||
'standardized.pdf'
|
||||
);
|
||||
showAlert(
|
||||
'Success',
|
||||
'Page sizes standardized successfully!',
|
||||
'success',
|
||||
() => {
|
||||
resetState();
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showAlert('Error', 'An error occurred while standardizing pages.');
|
||||
} finally {
|
||||
if (loaderModal) loaderModal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const targetSizeSelect = document.getElementById('target-size');
|
||||
const customSizeWrapper = document.getElementById('custom-size-wrapper');
|
||||
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
const processBtn = document.getElementById('process-btn');
|
||||
const backBtn = document.getElementById('back-to-tools');
|
||||
const targetSizeSelect = document.getElementById('target-size');
|
||||
const customSizeWrapper = document.getElementById('custom-size-wrapper');
|
||||
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function () {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
if (backBtn) {
|
||||
backBtn.addEventListener('click', function () {
|
||||
window.location.href = import.meta.env.BASE_URL;
|
||||
});
|
||||
}
|
||||
|
||||
// Setup custom size toggle
|
||||
if (targetSizeSelect && customSizeWrapper) {
|
||||
targetSizeSelect.addEventListener('change', function () {
|
||||
customSizeWrapper.classList.toggle(
|
||||
'hidden',
|
||||
(targetSizeSelect as HTMLSelectElement).value !== 'Custom'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', function (e) {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const pdfFiles = Array.from(files).filter(function (f) {
|
||||
return (
|
||||
f.type === 'application/pdf' ||
|
||||
f.name.toLowerCase().endsWith('.pdf')
|
||||
);
|
||||
});
|
||||
}
|
||||
if (pdfFiles.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(pdfFiles[0]);
|
||||
handleFileSelect(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Setup custom size toggle
|
||||
if (targetSizeSelect && customSizeWrapper) {
|
||||
targetSizeSelect.addEventListener('change', function () {
|
||||
customSizeWrapper.classList.toggle(
|
||||
'hidden',
|
||||
(targetSizeSelect as HTMLSelectElement).value !== 'Custom'
|
||||
);
|
||||
});
|
||||
}
|
||||
fileInput.addEventListener('click', function () {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (fileInput && dropZone) {
|
||||
fileInput.addEventListener('change', function (e) {
|
||||
handleFileSelect((e.target as HTMLInputElement).files);
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('bg-gray-700');
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
const pdfFiles = Array.from(files).filter(function (f) {
|
||||
return f.type === 'application/pdf' || f.name.toLowerCase().endsWith('.pdf');
|
||||
});
|
||||
if (pdfFiles.length > 0) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(pdfFiles[0]);
|
||||
handleFileSelect(dataTransfer.files);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('click', function () {
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', fixPageSize);
|
||||
}
|
||||
if (processBtn) {
|
||||
processBtn.addEventListener('click', fixPageSize);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,11 +10,18 @@ import {
|
||||
PDFDict,
|
||||
PDFArray,
|
||||
PDFRadioGroup,
|
||||
PDFTextField,
|
||||
PDFCheckBox,
|
||||
PDFDropdown,
|
||||
PDFOptionList,
|
||||
PDFButton,
|
||||
PDFSignature,
|
||||
} from 'pdf-lib';
|
||||
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
|
||||
import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js';
|
||||
import { createIcons, icons } from 'lucide';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import * as bwipjs from 'bwip-js/browser';
|
||||
import 'pdfjs-dist/web/pdf_viewer.css';
|
||||
|
||||
// Initialize PDF.js worker
|
||||
@@ -33,6 +40,7 @@ const existingRadioGroups: Set<string> = new Set();
|
||||
let draggedElement: HTMLElement | null = null;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
let pendingFieldExtraction = false;
|
||||
|
||||
let pages: PageData[] = [];
|
||||
let currentPageIndex = 0;
|
||||
@@ -384,8 +392,18 @@ function createField(type: FormField['type'], x: number, y: number): void {
|
||||
type: type,
|
||||
x: Math.max(0, Math.min(x, 816 - 150)),
|
||||
y: Math.max(0, Math.min(y, 1056 - 30)),
|
||||
width: type === 'checkbox' || type === 'radio' ? 30 : 150,
|
||||
height: type === 'checkbox' || type === 'radio' ? 30 : 30,
|
||||
width:
|
||||
type === 'checkbox' || type === 'radio'
|
||||
? 30
|
||||
: type === 'barcode'
|
||||
? 150
|
||||
: 150,
|
||||
height:
|
||||
type === 'checkbox' || type === 'radio'
|
||||
? 30
|
||||
: type === 'barcode'
|
||||
? 150
|
||||
: 30,
|
||||
name: `${type.charAt(0).toUpperCase() + type.slice(1)}_${fieldCounter}`,
|
||||
defaultValue: '',
|
||||
fontSize: 12,
|
||||
@@ -417,6 +435,8 @@ function createField(type: FormField['type'], x: number, y: number): void {
|
||||
multiline: type === 'text' ? false : undefined,
|
||||
borderColor: '#000000',
|
||||
hideBorder: false,
|
||||
barcodeFormat: type === 'barcode' ? 'qrcode' : undefined,
|
||||
barcodeValue: type === 'barcode' ? 'https://example.com' : undefined,
|
||||
};
|
||||
|
||||
fields.push(field);
|
||||
@@ -575,6 +595,32 @@ function renderField(field: FormField): void {
|
||||
'w-full h-full flex items-center justify-center bg-gray-100 text-gray-500 border border-gray-300';
|
||||
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1"><i data-lucide="image" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">${field.label || 'Click to Upload Image'}</span></div>`;
|
||||
setTimeout(() => (window as any).lucide?.createIcons(), 0);
|
||||
} else if (field.type === 'barcode') {
|
||||
contentEl.className =
|
||||
'w-full h-full flex items-center justify-center bg-white';
|
||||
if (field.barcodeValue) {
|
||||
try {
|
||||
const offscreen = document.createElement('canvas');
|
||||
bwipjs.toCanvas(offscreen, {
|
||||
bcid: field.barcodeFormat || 'qrcode',
|
||||
text: field.barcodeValue,
|
||||
scale: 2,
|
||||
includetext:
|
||||
field.barcodeFormat !== 'qrcode' &&
|
||||
field.barcodeFormat !== 'datamatrix',
|
||||
});
|
||||
const img = document.createElement('img');
|
||||
img.src = offscreen.toDataURL('image/png');
|
||||
img.className = 'max-w-full max-h-full object-contain';
|
||||
contentEl.appendChild(img);
|
||||
} catch {
|
||||
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Invalid data</span></div>`;
|
||||
setTimeout(() => (window as any).lucide?.createIcons(), 0);
|
||||
}
|
||||
} else {
|
||||
contentEl.innerHTML = `<div class="flex flex-col items-center text-center p-1 text-gray-400"><i data-lucide="qr-code" class="w-6 h-6 mb-1"></i><span class="text-[10px] leading-tight">Barcode</span></div>`;
|
||||
setTimeout(() => (window as any).lucide?.createIcons(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
fieldContainer.appendChild(contentEl);
|
||||
@@ -1114,6 +1160,26 @@ function showProperties(field: FormField): void {
|
||||
Clicking this field in the PDF will open a file picker to upload an image.
|
||||
</div>
|
||||
`;
|
||||
} else if (field.type === 'barcode') {
|
||||
specificProps = `
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Format</label>
|
||||
<select id="propBarcodeFormat" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<option value="qrcode" ${field.barcodeFormat === 'qrcode' ? 'selected' : ''}>QR Code</option>
|
||||
<option value="code128" ${field.barcodeFormat === 'code128' ? 'selected' : ''}>Code 128</option>
|
||||
<option value="code39" ${field.barcodeFormat === 'code39' ? 'selected' : ''}>Code 39</option>
|
||||
<option value="ean13" ${field.barcodeFormat === 'ean13' ? 'selected' : ''}>EAN-13</option>
|
||||
<option value="upca" ${field.barcodeFormat === 'upca' ? 'selected' : ''}>UPC-A</option>
|
||||
<option value="datamatrix" ${field.barcodeFormat === 'datamatrix' ? 'selected' : ''}>DataMatrix</option>
|
||||
<option value="pdf417" ${field.barcodeFormat === 'pdf417' ? 'selected' : ''}>PDF417</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold text-gray-300 mb-1">Barcode Value</label>
|
||||
<input type="text" id="propBarcodeValue" value="${field.barcodeValue || ''}" class="w-full bg-gray-600 border border-gray-500 text-white rounded px-2 py-1 text-sm focus:ring-indigo-500 focus:border-indigo-500">
|
||||
</div>
|
||||
<div id="barcodeFormatHint" class="text-xs text-gray-400 italic"></div>
|
||||
`;
|
||||
}
|
||||
|
||||
propertiesPanel.innerHTML = `
|
||||
@@ -1795,6 +1861,56 @@ function showProperties(field: FormField): void {
|
||||
field.label = (e.target as HTMLInputElement).value;
|
||||
renderField(field);
|
||||
});
|
||||
} else if (field.type === 'barcode') {
|
||||
const propBarcodeFormat = document.getElementById(
|
||||
'propBarcodeFormat'
|
||||
) as HTMLSelectElement;
|
||||
const propBarcodeValue = document.getElementById(
|
||||
'propBarcodeValue'
|
||||
) as HTMLInputElement;
|
||||
|
||||
const barcodeSampleValues: Record<string, string> = {
|
||||
qrcode: 'https://example.com',
|
||||
code128: 'ABC-123',
|
||||
code39: 'ABC123',
|
||||
ean13: '590123412345',
|
||||
upca: '01234567890',
|
||||
datamatrix: 'https://example.com',
|
||||
pdf417: 'https://example.com',
|
||||
};
|
||||
|
||||
const barcodeFormatHints: Record<string, string> = {
|
||||
qrcode: 'Any text, URL, or data',
|
||||
code128: 'ASCII characters (letters, numbers, symbols)',
|
||||
code39: 'Uppercase A-Z, digits 0-9, and - . $ / + % SPACE',
|
||||
ean13: 'Exactly 12 or 13 digits',
|
||||
upca: 'Exactly 11 or 12 digits',
|
||||
datamatrix: 'Any text, URL, or data',
|
||||
pdf417: 'Any text, URL, or data',
|
||||
};
|
||||
|
||||
const hintEl = document.getElementById('barcodeFormatHint');
|
||||
if (hintEl)
|
||||
hintEl.textContent =
|
||||
barcodeFormatHints[field.barcodeFormat || 'qrcode'] || '';
|
||||
|
||||
if (propBarcodeFormat) {
|
||||
propBarcodeFormat.addEventListener('change', (e) => {
|
||||
const newFormat = (e.target as HTMLSelectElement).value;
|
||||
field.barcodeFormat = newFormat;
|
||||
field.barcodeValue = barcodeSampleValues[newFormat] || 'hello';
|
||||
if (propBarcodeValue) propBarcodeValue.value = field.barcodeValue;
|
||||
if (hintEl) hintEl.textContent = barcodeFormatHints[newFormat] || '';
|
||||
renderField(field);
|
||||
});
|
||||
}
|
||||
|
||||
if (propBarcodeValue) {
|
||||
propBarcodeValue.addEventListener('input', (e) => {
|
||||
field.barcodeValue = (e.target as HTMLInputElement).value;
|
||||
renderField(field);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1911,6 +2027,19 @@ downloadBtn.addEventListener('click', async () => {
|
||||
|
||||
const form = pdfDoc.getForm();
|
||||
|
||||
if (extractedFieldNames.size > 0) {
|
||||
for (const fieldName of extractedFieldNames) {
|
||||
try {
|
||||
const existingField = form.getFieldMaybe(fieldName);
|
||||
if (existingField) {
|
||||
form.removeField(existingField);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to remove existing field "${fieldName}":`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
// Set document metadata for accessibility
|
||||
@@ -2332,6 +2461,32 @@ downloadBtn.addEventListener('click', async () => {
|
||||
if (field.tooltip) {
|
||||
widgetDict.set(PDFName.of('TU'), PDFString.of(field.tooltip));
|
||||
}
|
||||
} else if (field.type === 'barcode') {
|
||||
if (field.barcodeValue) {
|
||||
try {
|
||||
const offscreen = document.createElement('canvas');
|
||||
bwipjs.toCanvas(offscreen, {
|
||||
bcid: field.barcodeFormat || 'qrcode',
|
||||
text: field.barcodeValue,
|
||||
scale: 3,
|
||||
includetext:
|
||||
field.barcodeFormat !== 'qrcode' &&
|
||||
field.barcodeFormat !== 'datamatrix',
|
||||
});
|
||||
const dataUrl = offscreen.toDataURL('image/png');
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
const pngBytes = Uint8Array.from(atob(base64), (c) =>
|
||||
c.charCodeAt(0)
|
||||
);
|
||||
const pngImage = await pdfDoc.embedPng(pngBytes);
|
||||
pdfPage.drawImage(pngImage, { x, y, width, height });
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`Failed to generate barcode for field "${field.name}":`,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2429,6 +2584,8 @@ function resetToInitial(): void {
|
||||
currentPageIndex = 0;
|
||||
uploadedPdfDoc = null;
|
||||
selectedField = null;
|
||||
extractedFieldNames.clear();
|
||||
pendingFieldExtraction = false;
|
||||
|
||||
canvas.innerHTML = '';
|
||||
|
||||
@@ -2611,6 +2768,27 @@ async function renderCanvas(): Promise<void> {
|
||||
height: currentPage.height,
|
||||
},
|
||||
});
|
||||
|
||||
if (pendingFieldExtraction && uploadedPdfDoc) {
|
||||
pendingFieldExtraction = false;
|
||||
extractExistingFields(uploadedPdfDoc);
|
||||
extractedFieldNames.forEach((name) =>
|
||||
existingFieldNames.delete(name)
|
||||
);
|
||||
|
||||
try {
|
||||
const form = uploadedPdfDoc.getForm();
|
||||
for (const name of extractedFieldNames) {
|
||||
try {
|
||||
const f = form.getFieldMaybe(name);
|
||||
if (f) form.removeField(f);
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
renderCanvas();
|
||||
updateFieldCount();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
@@ -2701,6 +2879,235 @@ confirmBlankBtn.addEventListener('click', () => {
|
||||
setTimeout(() => createIcons({ icons }), 100);
|
||||
});
|
||||
|
||||
const extractedFieldNames: Set<string> = new Set();
|
||||
|
||||
function extractExistingFields(pdfDoc: PDFDocument): void {
|
||||
try {
|
||||
const form = pdfDoc.getForm();
|
||||
const pdfFields = form.getFields();
|
||||
const pdfPages = pdfDoc.getPages();
|
||||
|
||||
const pageRefToIndex = new Map<any, number>();
|
||||
pdfPages.forEach((page, index) => {
|
||||
pageRefToIndex.set(page.ref, index);
|
||||
});
|
||||
|
||||
for (const pdfField of pdfFields) {
|
||||
const name = pdfField.getName();
|
||||
const widgets = pdfField.acroField.getWidgets();
|
||||
|
||||
if (widgets.length === 0) continue;
|
||||
|
||||
let fieldType: FormField['type'];
|
||||
if (pdfField instanceof PDFTextField) {
|
||||
fieldType = 'text';
|
||||
} else if (pdfField instanceof PDFCheckBox) {
|
||||
fieldType = 'checkbox';
|
||||
} else if (pdfField instanceof PDFRadioGroup) {
|
||||
fieldType = 'radio';
|
||||
} else if (pdfField instanceof PDFDropdown) {
|
||||
fieldType = 'dropdown';
|
||||
} else if (pdfField instanceof PDFOptionList) {
|
||||
fieldType = 'optionlist';
|
||||
} else if (pdfField instanceof PDFButton) {
|
||||
fieldType = 'button';
|
||||
} else if (pdfField instanceof PDFSignature) {
|
||||
fieldType = 'signature';
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fieldType === 'radio') {
|
||||
const radioField = pdfField as PDFRadioGroup;
|
||||
const options = radioField.getOptions();
|
||||
|
||||
for (let wi = 0; wi < widgets.length; wi++) {
|
||||
const widget = widgets[wi];
|
||||
const rect = widget.getRectangle();
|
||||
|
||||
const pageRef = widget.dict.get(PDFName.of('P'));
|
||||
let pageIndex = 0;
|
||||
if (pageRef) {
|
||||
for (let pi = 0; pi < pdfPages.length; pi++) {
|
||||
if (pdfPages[pi].ref === pageRef) {
|
||||
pageIndex = pi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const page = pdfPages[pageIndex];
|
||||
const { height: pageHeight } = page.getSize();
|
||||
|
||||
const canvasX = rect.x * pdfViewerScale + pdfViewerOffset.x;
|
||||
const canvasY =
|
||||
(pageHeight - rect.y - rect.height) * pdfViewerScale +
|
||||
pdfViewerOffset.y;
|
||||
const canvasWidth = rect.width * pdfViewerScale;
|
||||
const canvasHeight = rect.height * pdfViewerScale;
|
||||
|
||||
fieldCounter++;
|
||||
const exportValue = options[wi] || 'Yes';
|
||||
|
||||
let tooltip = '';
|
||||
try {
|
||||
const tu = widget.dict.get(PDFName.of('TU'));
|
||||
if (tu instanceof PDFString) {
|
||||
tooltip = tu.decodeText();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const formField: FormField = {
|
||||
id: `field_${fieldCounter}`,
|
||||
type: 'radio',
|
||||
x: canvasX,
|
||||
y: canvasY,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
name: name,
|
||||
defaultValue: '',
|
||||
fontSize: 12,
|
||||
alignment: 'left',
|
||||
textColor: '#000000',
|
||||
required: radioField.isRequired(),
|
||||
readOnly: radioField.isReadOnly(),
|
||||
tooltip: tooltip,
|
||||
combCells: 0,
|
||||
maxLength: 0,
|
||||
checked: false,
|
||||
exportValue: exportValue,
|
||||
groupName: name,
|
||||
pageIndex: pageIndex,
|
||||
borderColor: '#000000',
|
||||
hideBorder: false,
|
||||
};
|
||||
|
||||
fields.push(formField);
|
||||
}
|
||||
|
||||
extractedFieldNames.add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
const widget = widgets[0];
|
||||
const rect = widget.getRectangle();
|
||||
|
||||
const pageRef = widget.dict.get(PDFName.of('P'));
|
||||
let pageIndex = 0;
|
||||
if (pageRef) {
|
||||
for (let pi = 0; pi < pdfPages.length; pi++) {
|
||||
if (pdfPages[pi].ref === pageRef) {
|
||||
pageIndex = pi;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const page = pdfPages[pageIndex];
|
||||
const { height: pageHeight } = page.getSize();
|
||||
|
||||
const canvasX = rect.x * pdfViewerScale + pdfViewerOffset.x;
|
||||
const canvasY =
|
||||
(pageHeight - rect.y - rect.height) * pdfViewerScale +
|
||||
pdfViewerOffset.y;
|
||||
const canvasWidth = rect.width * pdfViewerScale;
|
||||
const canvasHeight = rect.height * pdfViewerScale;
|
||||
|
||||
let tooltip = '';
|
||||
try {
|
||||
const tu = widget.dict.get(PDFName.of('TU'));
|
||||
if (tu instanceof PDFString) {
|
||||
tooltip = tu.decodeText();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
fieldCounter++;
|
||||
|
||||
const formField: FormField = {
|
||||
id: `field_${fieldCounter}`,
|
||||
type: fieldType,
|
||||
x: canvasX,
|
||||
y: canvasY,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
name: name,
|
||||
defaultValue: '',
|
||||
fontSize: 12,
|
||||
alignment: 'left',
|
||||
textColor: '#000000',
|
||||
required: pdfField.isRequired(),
|
||||
readOnly: pdfField.isReadOnly(),
|
||||
tooltip: tooltip,
|
||||
combCells: 0,
|
||||
maxLength: 0,
|
||||
pageIndex: pageIndex,
|
||||
borderColor: '#000000',
|
||||
hideBorder: false,
|
||||
};
|
||||
|
||||
if (pdfField instanceof PDFTextField) {
|
||||
try {
|
||||
formField.defaultValue = pdfField.getText() || '';
|
||||
} catch {}
|
||||
try {
|
||||
formField.multiline = pdfField.isMultiline();
|
||||
} catch {}
|
||||
try {
|
||||
const maxLen = pdfField.getMaxLength();
|
||||
if (maxLen !== undefined) {
|
||||
if (pdfField.isCombed()) {
|
||||
formField.combCells = maxLen;
|
||||
} else {
|
||||
formField.maxLength = maxLen;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const alignment = pdfField.getAlignment();
|
||||
if (alignment === TextAlignment.Center)
|
||||
formField.alignment = 'center';
|
||||
else if (alignment === TextAlignment.Right)
|
||||
formField.alignment = 'right';
|
||||
else formField.alignment = 'left';
|
||||
} catch {}
|
||||
} else if (pdfField instanceof PDFCheckBox) {
|
||||
try {
|
||||
formField.checked = pdfField.isChecked();
|
||||
} catch {}
|
||||
formField.exportValue = 'Yes';
|
||||
} else if (pdfField instanceof PDFDropdown) {
|
||||
try {
|
||||
formField.options = pdfField.getOptions();
|
||||
} catch {}
|
||||
try {
|
||||
const selected = pdfField.getSelected();
|
||||
if (selected.length > 0) formField.defaultValue = selected[0];
|
||||
} catch {}
|
||||
} else if (pdfField instanceof PDFOptionList) {
|
||||
try {
|
||||
formField.options = pdfField.getOptions();
|
||||
} catch {}
|
||||
try {
|
||||
const selected = pdfField.getSelected();
|
||||
if (selected.length > 0) formField.defaultValue = selected[0];
|
||||
} catch {}
|
||||
} else if (pdfField instanceof PDFButton) {
|
||||
formField.label = 'Button';
|
||||
formField.action = 'none';
|
||||
}
|
||||
|
||||
fields.push(formField);
|
||||
extractedFieldNames.add(name);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Extracted ${extractedFieldNames.size} existing fields for editing`
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Error extracting existing fields:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePdfUpload(file: File) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
@@ -2759,6 +3166,9 @@ async function handlePdfUpload(file: File) {
|
||||
}
|
||||
|
||||
currentPageIndex = 0;
|
||||
|
||||
pendingFieldExtraction = true;
|
||||
|
||||
renderCanvas();
|
||||
updatePageNavigation();
|
||||
|
||||
|
||||
@@ -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
@@ -1133,9 +1133,7 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
{ label: 'Top Right', value: 'top-right' },
|
||||
],
|
||||
orientation: [
|
||||
{ label: 'Vertical', value: 'vertical' },
|
||||
{ label: 'Horizontal', value: 'horizontal' },
|
||||
{ label: 'Auto', value: 'auto' },
|
||||
{ label: 'Auto (Keep Original)', value: 'auto' },
|
||||
{ label: 'Portrait', value: 'portrait' },
|
||||
{ label: 'Landscape', value: 'landscape' },
|
||||
],
|
||||
@@ -1160,6 +1158,23 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
{ label: 'Letter', value: 'letter' },
|
||||
{ label: 'Legal', value: 'legal' },
|
||||
],
|
||||
targetSize: [
|
||||
{ label: 'A4', value: 'A4' },
|
||||
{ label: 'Letter', value: 'Letter' },
|
||||
{ label: 'Legal', value: 'Legal' },
|
||||
{ label: 'A3', value: 'A3' },
|
||||
{ label: 'A5', value: 'A5' },
|
||||
{ label: 'Tabloid', value: 'Tabloid' },
|
||||
{ label: 'Custom', value: 'Custom' },
|
||||
],
|
||||
scalingMode: [
|
||||
{ label: 'Fit (keep full page visible)', value: 'fit' },
|
||||
{ label: 'Fill (cover full target page)', value: 'fill' },
|
||||
],
|
||||
customUnits: [
|
||||
{ label: 'Millimeters (mm)', value: 'mm' },
|
||||
{ label: 'Inches (in)', value: 'in' },
|
||||
],
|
||||
numberFormat: [
|
||||
{ label: 'Simple (1, 2, 3)', value: 'simple' },
|
||||
{ label: 'Page X of Y', value: 'page_x_of_y' },
|
||||
@@ -1294,6 +1309,9 @@ function showNodeSettings(node: BaseWorkflowNode) {
|
||||
text: ['text'],
|
||||
area: ['x0', 'y0', 'x1', 'y1'],
|
||||
},
|
||||
targetSize: {
|
||||
Custom: ['customWidth', 'customHeight', 'customUnits'],
|
||||
},
|
||||
};
|
||||
|
||||
const controlWrappers: Record<string, HTMLElement> = {};
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -8,3 +8,19 @@ export interface AddWatermarkState {
|
||||
watermarkX: number; // 0–1, percentage from left
|
||||
watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF)
|
||||
}
|
||||
|
||||
export interface PageWatermarkConfig {
|
||||
type: 'text' | 'image';
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
fontSize: number;
|
||||
color: string;
|
||||
opacityText: number;
|
||||
angleText: number;
|
||||
imageDataUrl: string | null;
|
||||
imageFile: File | null;
|
||||
imageScale: number;
|
||||
opacityImage: number;
|
||||
angleImage: number;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export interface DecryptPdfState {
|
||||
file: File | null;
|
||||
files: File[];
|
||||
}
|
||||
|
||||
@@ -1,40 +1,52 @@
|
||||
export interface FormField {
|
||||
id: string
|
||||
type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'optionlist' | 'button' | 'signature' | 'date' | 'image'
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
name: string
|
||||
defaultValue: string
|
||||
fontSize: number
|
||||
alignment: 'left' | 'center' | 'right'
|
||||
textColor: string
|
||||
required: boolean
|
||||
readOnly: boolean
|
||||
tooltip: string
|
||||
combCells: number
|
||||
maxLength: number
|
||||
options?: string[]
|
||||
checked?: boolean
|
||||
exportValue?: string
|
||||
groupName?: string
|
||||
label?: string
|
||||
pageIndex: number
|
||||
action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide'
|
||||
actionUrl?: string
|
||||
jsScript?: string
|
||||
targetFieldName?: string
|
||||
visibilityAction?: 'show' | 'hide' | 'toggle'
|
||||
dateFormat?: string
|
||||
multiline?: boolean
|
||||
borderColor?: string
|
||||
hideBorder?: boolean
|
||||
}
|
||||
|
||||
export interface PageData {
|
||||
index: number
|
||||
width: number
|
||||
height: number
|
||||
pdfPageData?: string
|
||||
}
|
||||
export interface FormField {
|
||||
id: string;
|
||||
type:
|
||||
| 'text'
|
||||
| 'checkbox'
|
||||
| 'radio'
|
||||
| 'dropdown'
|
||||
| 'optionlist'
|
||||
| 'button'
|
||||
| 'signature'
|
||||
| 'date'
|
||||
| 'image'
|
||||
| 'barcode';
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
name: string;
|
||||
defaultValue: string;
|
||||
fontSize: number;
|
||||
alignment: 'left' | 'center' | 'right';
|
||||
textColor: string;
|
||||
required: boolean;
|
||||
readOnly: boolean;
|
||||
tooltip: string;
|
||||
combCells: number;
|
||||
maxLength: number;
|
||||
options?: string[];
|
||||
checked?: boolean;
|
||||
exportValue?: string;
|
||||
groupName?: string;
|
||||
label?: string;
|
||||
pageIndex: number;
|
||||
action?: 'none' | 'reset' | 'print' | 'url' | 'js' | 'showHide';
|
||||
actionUrl?: string;
|
||||
jsScript?: string;
|
||||
targetFieldName?: string;
|
||||
visibilityAction?: 'show' | 'hide' | 'toggle';
|
||||
dateFormat?: string;
|
||||
multiline?: boolean;
|
||||
borderColor?: string;
|
||||
hideBorder?: boolean;
|
||||
barcodeFormat?: string;
|
||||
barcodeValue?: string;
|
||||
}
|
||||
|
||||
export interface PageData {
|
||||
index: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pdfPageData?: string;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PDFDocument, degrees, rgb, StandardFonts } from 'pdf-lib';
|
||||
import { PDFDocument, degrees, rgb, StandardFonts, PageSizes } from 'pdf-lib';
|
||||
|
||||
export async function mergePdfs(
|
||||
pdfBytesList: Uint8Array[]
|
||||
@@ -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(
|
||||
@@ -438,3 +438,87 @@ export async function addPageNumbers(
|
||||
|
||||
return new Uint8Array(await pdfDoc.save());
|
||||
}
|
||||
|
||||
export interface FixPageSizeOptions {
|
||||
targetSize: string;
|
||||
orientation: string;
|
||||
scalingMode: string;
|
||||
backgroundColor: { r: number; g: number; b: number };
|
||||
customWidth?: number;
|
||||
customHeight?: number;
|
||||
customUnits?: string;
|
||||
}
|
||||
|
||||
export async function fixPageSize(
|
||||
pdfBytes: Uint8Array,
|
||||
options: FixPageSizeOptions
|
||||
): Promise<Uint8Array> {
|
||||
let targetWidth: number;
|
||||
let targetHeight: number;
|
||||
|
||||
if (options.targetSize.toLowerCase() === 'custom') {
|
||||
const w = options.customWidth ?? 210;
|
||||
const h = options.customHeight ?? 297;
|
||||
const units = (options.customUnits ?? 'mm').toLowerCase();
|
||||
if (units === 'in') {
|
||||
targetWidth = w * 72;
|
||||
targetHeight = h * 72;
|
||||
} else {
|
||||
targetWidth = w * (72 / 25.4);
|
||||
targetHeight = h * (72 / 25.4);
|
||||
}
|
||||
} else {
|
||||
const selected =
|
||||
PageSizes[options.targetSize as keyof typeof PageSizes] || PageSizes.A4;
|
||||
targetWidth = selected[0];
|
||||
targetHeight = selected[1];
|
||||
}
|
||||
|
||||
const orientation = options.orientation.toLowerCase();
|
||||
if (orientation === 'landscape' && targetWidth < targetHeight) {
|
||||
[targetWidth, targetHeight] = [targetHeight, targetWidth];
|
||||
} else if (orientation === 'portrait' && targetWidth > targetHeight) {
|
||||
[targetWidth, targetHeight] = [targetHeight, targetWidth];
|
||||
}
|
||||
|
||||
const sourceDoc = await PDFDocument.load(pdfBytes);
|
||||
const outputDoc = await PDFDocument.create();
|
||||
|
||||
for (const sourcePage of sourceDoc.getPages()) {
|
||||
const { width: sourceWidth, height: sourceHeight } = sourcePage.getSize();
|
||||
const embeddedPage = await outputDoc.embedPage(sourcePage);
|
||||
|
||||
const outputPage = outputDoc.addPage([targetWidth, targetHeight]);
|
||||
outputPage.drawRectangle({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
color: rgb(
|
||||
options.backgroundColor.r,
|
||||
options.backgroundColor.g,
|
||||
options.backgroundColor.b
|
||||
),
|
||||
});
|
||||
|
||||
const scaleX = targetWidth / sourceWidth;
|
||||
const scaleY = targetHeight / sourceHeight;
|
||||
const useFill = options.scalingMode.toLowerCase() === 'fill';
|
||||
const scale = useFill ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY);
|
||||
|
||||
const scaledWidth = sourceWidth * scale;
|
||||
const scaledHeight = sourceHeight * scale;
|
||||
|
||||
const x = (targetWidth - scaledWidth) / 2;
|
||||
const y = (targetHeight - scaledHeight) / 2;
|
||||
|
||||
outputPage.drawPage(embeddedPage, {
|
||||
x,
|
||||
y,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
});
|
||||
}
|
||||
|
||||
return new Uint8Array(await outputDoc.save());
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { BaseWorkflowNode } from './base-node';
|
||||
import { pdfSocket } from '../sockets';
|
||||
import type { SocketData } from '../types';
|
||||
import { requirePdfInput, processBatch } from '../types';
|
||||
import { fixPageSize } from '../../utils/pdf-operations';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { hexToRgb } from '../../utils/helpers.js';
|
||||
|
||||
export class FixPageSizeNode extends BaseWorkflowNode {
|
||||
readonly category = 'Organize & Manage' as const;
|
||||
readonly icon = 'ph-frame-corners';
|
||||
readonly description = 'Standardize all pages to a target size';
|
||||
|
||||
constructor() {
|
||||
super('Fix Page Size');
|
||||
this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF'));
|
||||
this.addOutput(
|
||||
'pdf',
|
||||
new ClassicPreset.Output(pdfSocket, 'Standardized PDF')
|
||||
);
|
||||
this.addControl(
|
||||
'targetSize',
|
||||
new ClassicPreset.InputControl('text', { initial: 'A4' })
|
||||
);
|
||||
this.addControl(
|
||||
'orientation',
|
||||
new ClassicPreset.InputControl('text', { initial: 'auto' })
|
||||
);
|
||||
this.addControl(
|
||||
'scalingMode',
|
||||
new ClassicPreset.InputControl('text', { initial: 'fit' })
|
||||
);
|
||||
this.addControl(
|
||||
'backgroundColor',
|
||||
new ClassicPreset.InputControl('text', { initial: '#ffffff' })
|
||||
);
|
||||
this.addControl(
|
||||
'customWidth',
|
||||
new ClassicPreset.InputControl('number', { initial: 210 })
|
||||
);
|
||||
this.addControl(
|
||||
'customHeight',
|
||||
new ClassicPreset.InputControl('number', { initial: 297 })
|
||||
);
|
||||
this.addControl(
|
||||
'customUnits',
|
||||
new ClassicPreset.InputControl('text', { initial: 'mm' })
|
||||
);
|
||||
}
|
||||
|
||||
async data(
|
||||
inputs: Record<string, SocketData[]>
|
||||
): Promise<Record<string, SocketData>> {
|
||||
const pdfInputs = requirePdfInput(inputs, 'Fix Page Size');
|
||||
|
||||
const getText = (key: string, fallback: string) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'text'>
|
||||
| undefined;
|
||||
return (ctrl?.value || fallback).trim();
|
||||
};
|
||||
|
||||
const getNum = (key: string, fallback: number) => {
|
||||
const ctrl = this.controls[key] as
|
||||
| ClassicPreset.InputControl<'number'>
|
||||
| undefined;
|
||||
const value = ctrl?.value;
|
||||
return Number.isFinite(value) ? (value as number) : fallback;
|
||||
};
|
||||
|
||||
const targetSize = getText('targetSize', 'A4');
|
||||
const orientation = getText('orientation', 'auto');
|
||||
const scalingMode = getText('scalingMode', 'fit');
|
||||
const backgroundHex = getText('backgroundColor', '#ffffff');
|
||||
const customWidth = Math.max(1, getNum('customWidth', 210));
|
||||
const customHeight = Math.max(1, getNum('customHeight', 297));
|
||||
const customUnits = getText('customUnits', 'mm');
|
||||
const backgroundColor = hexToRgb(backgroundHex);
|
||||
|
||||
return {
|
||||
pdf: await processBatch(pdfInputs, async (input) => {
|
||||
const resultBytes = await fixPageSize(input.bytes, {
|
||||
targetSize,
|
||||
orientation,
|
||||
scalingMode,
|
||||
backgroundColor,
|
||||
customWidth,
|
||||
customHeight,
|
||||
customUnits,
|
||||
});
|
||||
|
||||
const resultDoc = await PDFDocument.load(resultBytes);
|
||||
|
||||
return {
|
||||
type: 'pdf',
|
||||
document: resultDoc,
|
||||
bytes: resultBytes,
|
||||
filename: input.filename.replace(/\.pdf$/i, '_standardized.pdf'),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { ReversePagesNode } from './reverse-pages-node';
|
||||
import { AddBlankPageNode } from './add-blank-page-node';
|
||||
import { DividePagesNode } from './divide-pages-node';
|
||||
import { NUpNode } from './n-up-node';
|
||||
import { FixPageSizeNode } from './fix-page-size-node';
|
||||
import { CombineSinglePageNode } from './combine-single-page-node';
|
||||
import { CropNode } from './crop-node';
|
||||
import { GreyscaleNode } from './greyscale-node';
|
||||
@@ -298,6 +299,13 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
|
||||
description: 'Arrange multiple pages per sheet',
|
||||
factory: () => new NUpNode(),
|
||||
},
|
||||
FixPageSizeNode: {
|
||||
label: 'Fix Page Size',
|
||||
category: 'Organize & Manage',
|
||||
icon: 'ph-frame-corners',
|
||||
description: 'Standardize all pages to a target size',
|
||||
factory: () => new FixPageSizeNode(),
|
||||
},
|
||||
CombineSinglePageNode: {
|
||||
label: 'Combine to Single Page',
|
||||
category: 'Organize & Manage',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -132,10 +132,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:addAttachments.name"
|
||||
>
|
||||
Add Attachments Free Online - Fast & Secure
|
||||
Add Attachments
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:addAttachments.subtitle">
|
||||
Upload a PDF, then add files to embed as attachments.
|
||||
Embed one or more files into your PDF.
|
||||
</p>
|
||||
|
||||
<!-- PDF Upload -->
|
||||
|
||||
@@ -126,10 +126,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:addBlankPage.name"
|
||||
>
|
||||
Add Blank Page Free Online - Fast & Secure
|
||||
Add Blank Page
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:addBlankPage.subtitle">
|
||||
Insert one or more blank pages at any position in your PDF document.
|
||||
Insert an empty page anywhere in your PDF.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -116,11 +116,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:addStamps.name"
|
||||
>
|
||||
Add Stamps Free Online - Fast & Secure
|
||||
Add Stamps
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:addStamps.subtitle">
|
||||
Upload a PDF, add image stamps using the toolbar, and then save the
|
||||
stamped PDF.
|
||||
Add image stamps to your PDF using the annotation toolbar.
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:addWatermark.name"
|
||||
>
|
||||
Add Watermark to PDF Free - Protect PDFs
|
||||
Add Watermark
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:addWatermark.subtitle">
|
||||
Add text or image watermarks to your PDF documents with customizable
|
||||
opacity and rotation.
|
||||
Stamp text or an image over your PDF pages.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -118,10 +118,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:adjustColors.name"
|
||||
>
|
||||
Adjust Colors Free Online - PDF Color Correction Tool
|
||||
Adjust Colors
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:adjustColors.subtitle">
|
||||
Fine-tune brightness, contrast, saturation, hue and more in your PDF.
|
||||
Fine-tune brightness, contrast, saturation and more in your PDF.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -132,11 +132,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:alternateMix.name"
|
||||
>
|
||||
Alternate Merge Free Online - Combine PDFs Instantly
|
||||
Alternate & Mix Pages
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:alternateMix.subtitle">
|
||||
Upload multiple PDFs and interleave their pages. Drag to reorder the
|
||||
files, then mix their pages alternately.
|
||||
Merge PDFs by alternating pages from each PDF. Preserves Bookmarks.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -134,13 +134,13 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:backgroundColor.name"
|
||||
>
|
||||
Background Color Free Online - Fast & Secure
|
||||
Background Color
|
||||
</h1>
|
||||
<p
|
||||
class="text-gray-400 mb-6"
|
||||
data-i18n="tools:backgroundColor.subtitle"
|
||||
>
|
||||
Add or change the background color of all pages in your PDF document.
|
||||
Change the background color of your PDF.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,10 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:bmpToPdf.name"
|
||||
>
|
||||
Bmp To Pdf Converter Free Online - Convert Files Fast
|
||||
BMP to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:bmpToPdf.subtitle">
|
||||
Convert one or more BMP images into a single PDF file.
|
||||
Create a PDF from one or more BMP images.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:editBookmarks.name"
|
||||
>
|
||||
Bookmark Free Online - Fast & Secure
|
||||
Edit Bookmarks
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:editBookmarks.subtitle">
|
||||
Add, edit, import, delete and extract PDF bookmarks.
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:cbzToPdf.name"
|
||||
>
|
||||
Cbz To Pdf Converter Free Online - Convert Files Fast
|
||||
CBZ to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:cbzToPdf.subtitle">
|
||||
Convert comic book archives (CBZ/CBR) to PDF format. Supports multiple
|
||||
|
||||
@@ -134,14 +134,13 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:changePermissions.name"
|
||||
>
|
||||
Change Permissions Free Online - Fast & Secure
|
||||
Change Permissions
|
||||
</h1>
|
||||
<p
|
||||
class="text-gray-400 mb-6"
|
||||
data-i18n="tools:changePermissions.subtitle"
|
||||
>
|
||||
Set or change user permissions on a PDF. Modify passwords and access
|
||||
controls.
|
||||
Set or change user permissions on a PDF.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -134,14 +134,13 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:combineToSinglePage.name"
|
||||
>
|
||||
Combine Single Page Free Online - Combine PDFs Instantly
|
||||
Combine to Single Page
|
||||
</h1>
|
||||
<p
|
||||
class="text-gray-400 mb-6"
|
||||
data-i18n="tools:combineToSinglePage.subtitle"
|
||||
>
|
||||
Merge all pages of your PDF into one continuous page, either
|
||||
horizontally or vertically.
|
||||
Stitch all pages into one continuous scroll.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -150,11 +150,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:comparePdfs.name"
|
||||
>
|
||||
Compare Pdfs Free Online - Fast & Secure
|
||||
Compare PDFs
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:comparePdfs.subtitle">
|
||||
Upload two PDF files to compare them side by side or with overlay
|
||||
mode.
|
||||
Compare two PDFs side by side.
|
||||
</p>
|
||||
|
||||
<!-- File Upload Areas -->
|
||||
|
||||
@@ -119,11 +119,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:compressPdf.name"
|
||||
>
|
||||
Compress PDF Free Online - Reduce File Size Fast
|
||||
Compress PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:compressPdf.subtitle">
|
||||
Reduce file size by choosing the compression method that best suits
|
||||
your document. Supports multiple PDFs.
|
||||
Reduce the file size of your PDF.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -125,11 +125,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:cropPdf.name"
|
||||
>
|
||||
Crop Pdf Free Online - Fast & Secure
|
||||
Crop PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:cropPdf.subtitle">
|
||||
Upload a PDF to visually crop one or more pages. This tool offers a
|
||||
live preview and two distinct cropping modes.
|
||||
Trim the margins of every page in your PDF.
|
||||
</p>
|
||||
|
||||
<!-- Drop Zone -->
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:csvToPdf.name"
|
||||
>
|
||||
Csv To Pdf Converter Free Online - Convert Files Fast
|
||||
CSV to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:csvToPdf.subtitle">
|
||||
Convert CSV (Comma-Separated Values) spreadsheet files to PDF format.
|
||||
Supports multiple files.
|
||||
Convert CSV spreadsheet files to PDF format. Supports multiple files.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,10 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:decryptPdf.name"
|
||||
>
|
||||
Decrypt PDF Free - Remove Password Online
|
||||
Decrypt PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:decryptPdf.subtitle">
|
||||
Unlock password-protected PDF files by removing encryption.
|
||||
Unlock PDF by removing password protection.
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -141,6 +141,12 @@
|
||||
>
|
||||
<span data-i18n="upload.orDragAndDrop">or drag and drop</span>
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-gray-500"
|
||||
data-i18n="upload.hints.singleOrMultiplePdfs"
|
||||
>
|
||||
Single or multiple PDF files supported
|
||||
</p>
|
||||
<p class="text-xs text-gray-500" data-i18n="upload.filesNeverLeave">
|
||||
Your files never leave your device.
|
||||
</p>
|
||||
@@ -150,9 +156,28 @@
|
||||
type="file"
|
||||
class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Control buttons for multi-file uploads -->
|
||||
<div id="file-controls" class="hidden mt-4 flex gap-3">
|
||||
<button
|
||||
id="add-more-btn"
|
||||
class="btn bg-indigo-600 hover:bg-indigo-700 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<i data-lucide="plus"></i>
|
||||
<span data-i18n="upload.addMore">Add More Files</span>
|
||||
</button>
|
||||
<button
|
||||
id="clear-files-btn"
|
||||
class="btn bg-gray-600 hover:bg-gray-500 text-white font-semibold px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<i data-lucide="trash-2"></i>
|
||||
<span data-i18n="upload.clearAll">Clear All</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="file-display-area" class="mt-4 space-y-2"></div>
|
||||
|
||||
<div id="tool-options" class="hidden mt-6 space-y-4">
|
||||
|
||||
@@ -119,10 +119,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:deletePages.name"
|
||||
>
|
||||
Delete PDF Pages Free - Remove Pages Online
|
||||
Delete Pages
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:deletePages.subtitle">
|
||||
Remove specific pages or ranges of pages from your PDF file.
|
||||
Remove specific pages from your document.
|
||||
</p>
|
||||
<div
|
||||
id="drop-zone"
|
||||
|
||||
@@ -108,11 +108,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:deskewPdf.name"
|
||||
>
|
||||
Deskew PDF Free Online - Straighten Tilted Scans
|
||||
Deskew PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:deskewPdf.subtitle">
|
||||
Automatically detect and correct skewed pages in scanned PDFs. Uses
|
||||
advanced image processing to straighten tilted documents.
|
||||
Automatically straighten tilted scanned pages using OpenCV.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:dividePages.name"
|
||||
>
|
||||
Divide Pages Free Online - Fast & Secure
|
||||
Divide Pages
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:dividePages.subtitle">
|
||||
Split each page of your PDF horizontally or vertically, turning one
|
||||
page into two.
|
||||
Divide pages horizontally or vertically.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -134,13 +134,13 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:editAttachments.name"
|
||||
>
|
||||
Edit Attachments Free Online - Edit PDFs Securely
|
||||
Edit Attachments
|
||||
</h1>
|
||||
<p
|
||||
class="text-gray-400 mb-6"
|
||||
data-i18n="tools:editAttachments.subtitle"
|
||||
>
|
||||
Upload a PDF to view and remove its embedded attachments.
|
||||
View or remove attachments in your PDF.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:editMetadata.name"
|
||||
>
|
||||
Edit Metadata Free Online - Edit PDFs Securely
|
||||
Edit Metadata
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:editMetadata.subtitle">
|
||||
Edit metadata fields in your PDF document including title, author,
|
||||
subject, and keywords.
|
||||
Change the author, title, and other properties.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:pdfEditor.name"
|
||||
>
|
||||
Edit PDF Free Online - Modify PDFs Securely
|
||||
PDF Editor
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:pdfEditor.subtitle">
|
||||
Annotate, highlight, redact, comment, add shapes/images, search, and
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:emailToPdf.name"
|
||||
>
|
||||
Email to PDF Converter Free Online - Convert EML MSG Files
|
||||
Email to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:emailToPdf.subtitle">
|
||||
Convert email files (EML, MSG) to PDF format. Supports Outlook exports
|
||||
|
||||
@@ -123,11 +123,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:encryptPdf.name"
|
||||
>
|
||||
Encrypt PDF Free - Password Protect PDFs
|
||||
Encrypt PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:encryptPdf.subtitle">
|
||||
Protect your PDF with 256-bit AES encryption. Add password protection
|
||||
and set usage restrictions.
|
||||
Lock your PDF by adding a password.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:epubToPdf.name"
|
||||
>
|
||||
Epub To Pdf Converter Free Online - Convert Files Fast
|
||||
EPUB to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:epubToPdf.subtitle">
|
||||
Convert EPUB e-books to PDF format. Supports multiple files.
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:excelToPdf.name"
|
||||
>
|
||||
Excel to PDF Converter Free - Convert XLSX Online
|
||||
Excel to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:excelToPdf.subtitle">
|
||||
Convert Excel spreadsheets (XLSX, XLS, ODS, CSV) to PDF format.
|
||||
|
||||
@@ -134,14 +134,13 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:extractAttachments.name"
|
||||
>
|
||||
Extract Attachments Free Online - Extract PDF Content
|
||||
Extract Attachments
|
||||
</h1>
|
||||
<p
|
||||
class="text-gray-400 mb-6"
|
||||
data-i18n="tools:extractAttachments.subtitle"
|
||||
>
|
||||
Upload PDF files to extract all embedded attachments. Files will be
|
||||
downloaded as a ZIP archive.
|
||||
Extract all embedded files from PDF(s) as a ZIP.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -126,11 +126,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:extractImages.name"
|
||||
>
|
||||
Extract Images Free Online - Extract PDF Content
|
||||
Extract Images
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:extractImages.subtitle">
|
||||
Extract all embedded images from PDF files. Download individually or
|
||||
as a ZIP archive.
|
||||
Extract all embedded images from your PDF files.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:extractPages.name"
|
||||
>
|
||||
Extract PDF Pages Free - Copy Pages Online
|
||||
Extract Pages
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:extractPages.subtitle">
|
||||
Extract specific pages from a PDF into separate files. Your files will
|
||||
download in a ZIP archive.
|
||||
Save a selection of pages as new files.
|
||||
</p>
|
||||
|
||||
<!-- Drop Zone -->
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:extractTables.name"
|
||||
>
|
||||
Extract Tables Free Online - Extract PDF Content
|
||||
Extract PDF Tables
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:extractTables.subtitle">
|
||||
Extract tables from PDF files and export as CSV, JSON, or Markdown.
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:fb2ToPdf.name"
|
||||
>
|
||||
Fb2 To Pdf Converter Free Online - Convert Files Fast
|
||||
FB2 to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:fb2ToPdf.subtitle">
|
||||
Convert FictionBook (FB2) e-books to PDF format. Supports multiple
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:fixPageSize.name"
|
||||
>
|
||||
Fix Page Size Free Online - Fast & Secure
|
||||
Fix Page Size
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:fixPageSize.subtitle">
|
||||
Standardize all pages in your PDF to a uniform size. Choose from
|
||||
standard sizes or set custom dimensions.
|
||||
Standardize all pages to a uniform size.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:flattenPdf.name"
|
||||
>
|
||||
Flatten Pdf Free Online - Fast & Secure
|
||||
Flatten PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:flattenPdf.subtitle">
|
||||
Make form fields and annotations non-editable by flattening them into
|
||||
the PDF.
|
||||
Make form fields and annotations non-editable.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -119,12 +119,11 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:fontToOutline.name"
|
||||
>
|
||||
Font to Outline Free Online - Convert Fonts to Paths
|
||||
Font to Outline
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:fontToOutline.subtitle">
|
||||
Convert all fonts in your PDF to vector outlines/paths. Ensures
|
||||
consistent rendering across all devices regardless of font
|
||||
availability.
|
||||
Convert all fonts to vector outlines for consistent rendering across
|
||||
all devices.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:createPdfForm.name"
|
||||
>
|
||||
Form Creator Free Online - Fast & Secure
|
||||
Create PDF Form
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:createPdfForm.subtitle">
|
||||
Upload an existing PDF or create a blank PDF to start adding form
|
||||
fields.
|
||||
Create fillable PDF forms with drag-and-drop text fields.
|
||||
</p>
|
||||
|
||||
<div
|
||||
@@ -262,8 +261,7 @@
|
||||
Create PDF Form
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:createPdfForm.subtitle">
|
||||
Drag and drop fields onto the canvas to create a fillable PDF form.
|
||||
Customize field properties and download your form.
|
||||
Create fillable PDF forms with drag-and-drop text fields.
|
||||
</p>
|
||||
|
||||
<!-- Main Form Creator Area -->
|
||||
@@ -504,6 +502,16 @@
|
||||
<i data-lucide="image" class="w-4 h-4"></i>
|
||||
<span class="text-xs">Image</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tool-item bg-gray-600 hover:bg-indigo-600 text-white p-2 rounded cursor-move transition-colors flex items-center gap-2"
|
||||
draggable="true"
|
||||
data-type="barcode"
|
||||
title="Barcode Field"
|
||||
>
|
||||
<i data-lucide="qr-code" class="w-4 h-4"></i>
|
||||
<span class="text-xs">Barcode</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-700 rounded-lg p-4 mb-4 overflow-auto w-full">
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:pdfFormFiller.name"
|
||||
>
|
||||
Form Filler Free Online - Fast & Secure
|
||||
PDF Form Filler
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Upload a PDF with form fields. Fill them directly in the viewer below,
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:headerFooter.name"
|
||||
>
|
||||
Header Footer Free Online - Fast & Secure
|
||||
Header & Footer
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:headerFooter.subtitle">
|
||||
Add customizable headers and footers to your PDF documents with
|
||||
dynamic page numbers.
|
||||
Add text to the top and bottom of pages.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:heicToPdf.name"
|
||||
>
|
||||
Heic To Pdf Converter Free Online - Convert Files Fast
|
||||
HEIC to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:heicToPdf.subtitle">
|
||||
Convert one or more HEIC (High Efficiency) images from your iPhone or
|
||||
camera into a single PDF file.
|
||||
Create a PDF from one or more HEIC images.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -121,10 +121,11 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:imageToPdf.name"
|
||||
>
|
||||
Image To Pdf Converter Free Online - Convert Files Fast
|
||||
Images to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:imageToPdf.subtitle">
|
||||
Convert one or more images into a single PDF file.
|
||||
Convert JPG, PNG, BMP, GIF, TIFF, PNM, PGM, PBM, PPM, PAM, JXR, JPX,
|
||||
JP2, PSD, SVG, HEIC, WebP to PDF.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:invertColors.name"
|
||||
>
|
||||
Invert Colors Free Online - Fast & Secure
|
||||
Invert Colors
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:invertColors.subtitle">
|
||||
Invert all colors in your PDF document, creating a negative image
|
||||
effect.
|
||||
Create a "dark mode" version of your PDF.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:jpgToPdf.name"
|
||||
>
|
||||
JPG to PDF Converter Free - Convert Images Online
|
||||
JPG to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:jpgToPdf.subtitle">
|
||||
Create a PDF from JPG, JPEG, and JPEG2000 (JP2/JPX) images.
|
||||
|
||||
@@ -112,11 +112,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:jsonToPdf.name"
|
||||
>
|
||||
Json To Pdf Converter Free Online - Convert Files Fast
|
||||
JSON to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:jsonToPdf.subtitle">
|
||||
Upload multiple JSON files to convert them all to PDF format. Files
|
||||
will be downloaded as a ZIP archive.
|
||||
Convert JSON files to PDF format.
|
||||
</p>
|
||||
<div
|
||||
class="bg-yellow-900/30 border border-yellow-700 rounded-lg p-3 mb-6"
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:linearizePdf.name"
|
||||
>
|
||||
Linearize Pdf Free Online - Fast & Secure
|
||||
Linearize PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:linearizePdf.subtitle">
|
||||
Optimize PDF files for fast web viewing. Linearized PDFs load
|
||||
progressively in browsers.
|
||||
Optimize PDF for fast web viewing.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -130,14 +130,14 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:markdownToPdf.name"
|
||||
>
|
||||
Markdown To Pdf Converter Free Online - Convert Files Fast
|
||||
Markdown to PDF
|
||||
</h1>
|
||||
<p
|
||||
class="text-gray-400 mb-6"
|
||||
data-i18n="tools:markdownToPdf.subtitle"
|
||||
>
|
||||
Write markdown with live preview. Supports syntax highlighting,
|
||||
tables, emoji, and more. Print to save as PDF.
|
||||
Write or paste Markdown and export it as a beautifully formatted
|
||||
PDF.
|
||||
</p>
|
||||
|
||||
<!-- Editor container -->
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:mergePdf.name"
|
||||
>
|
||||
Merge PDF Files Free - Combine PDFs Instantly
|
||||
Merge PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:mergePdf.subtitle">
|
||||
Combine whole files, or select specific pages to merge into a new
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:mobiToPdf.name"
|
||||
>
|
||||
Mobi To Pdf Converter Free Online - Convert Files Fast
|
||||
MOBI to PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:mobiToPdf.subtitle">
|
||||
Convert MOBI e-books to PDF format. Supports multiple files.
|
||||
|
||||
@@ -120,11 +120,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:nUpPdf.name"
|
||||
>
|
||||
N Up Pdf Free Online - Fast & Secure
|
||||
N-Up PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:nUpPdf.subtitle">
|
||||
Arrange multiple pages onto a single sheet. Perfect for printing
|
||||
handouts or saving paper.
|
||||
Arrange multiple pages onto a single sheet.
|
||||
</p>
|
||||
|
||||
<div
|
||||
|
||||
@@ -123,11 +123,10 @@
|
||||
class="text-2xl font-bold text-white mb-2"
|
||||
data-i18n="tools:ocrPdf.name"
|
||||
>
|
||||
OCR PDF Free Online - Extract Text from Scans
|
||||
OCR PDF
|
||||
</h1>
|
||||
<p class="text-gray-400 mb-6" data-i18n="tools:ocrPdf.subtitle">
|
||||
Convert scanned PDFs into searchable documents. Select one or more
|
||||
languages present in your file for the best results.
|
||||
Make a PDF searchable and copyable.
|
||||
</p>
|
||||
|
||||
<!-- How it works info box -->
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user