Compare commits

...
29 Commits
Author SHA1 Message Date
alam00000 98c65d2b45 Release v2.3.3 2026-02-27 14:03:58 +05:30
alam00000 1e7ef30fac Release v2.3.2 2026-02-27 14:02:13 +05:30
Alam 0eb7dc7114 Merge pull request #480 from alam00000/dependabot/npm_and_yarn/markdown-it-14.1.1
build(deps): bump markdown-it from 14.1.0 to 14.1.1
2026-02-26 20:34:56 +05:30
Alam 9e96abfbb1 Merge pull request #496 from alam00000/dependabot/npm_and_yarn/qs-6.15.0
build(deps-dev): bump qs from 6.14.1 to 6.15.0
2026-02-26 20:34:44 +05:30
Alam 9220507f63 Merge pull request #519 from hagibr/update-tool-translation-keys
Adding 'PDF Workflow Builder' and 'Bates Numbering' to toolTranslationKeys
2026-02-26 20:34:25 +05:30
Alam 0882bc9bc0 Merge pull request #518 from hagibr/update-portuguese-translation
Correction on pdfToExcel.name (Portuguese)
2026-02-26 20:33:06 +05:30
alam00000 85d90c3382 feat: node creation and serialization logic in workflow editor 2026-02-26 20:28:16 +05:30
alam00000 88260c26ab Refactor color input fields and enhance watermark functionality
- Updated color input fields in various HTML pages to remove unnecessary classes for improved styling consistency.
- Modified the watermark node to include options for positioning and flattening watermarks.
- Enhanced the addTextWatermark function to support customizable positioning and page selection for watermarks.
- Added new controls for text and image watermarks in the UI, allowing users to specify text, font size, color, opacity, angle, and image scaling.
- Updated the WASM provider to use the latest version of pymupdf-wasm.
2026-02-26 19:40:22 +05:30
Alexandre Hagihara 582e5bf9cb Adding 'PDF Workflow Builder' and 'Bates Numbering' to toolTranslationKeys 2026-02-26 09:38:01 -03:00
Alexandre Hagihara 6e47777c15 Correction on pdfToExcel.name 2026-02-26 09:06:34 -03:00
Alam 2e64e8e8d4 Merge pull request #516 from hagibr/update-portuguese-translation
Updating the portuguese translations for the new tools
2026-02-26 04:54:44 +05:30
github-actions[bot] 93d012c92a @hagibr has signed the CLA in alam00000/bentopdf#516 2026-02-25 18:02:44 +00:00
Alexandre Hagihara f5953d8c87 Updating the portuguese translations for the new tools 2026-02-25 14:34:28 -03:00
alam00000 64153e698b fix: improve error handling, race condition and optimize page rendering logic in pdf multi tool 2026-02-25 13:17:30 +05:30
Alam 518c18664d Merge pull request #502 from alam00000/dependabot/npm_and_yarn/svelte-5.53.1
build(deps): bump svelte from 5.48.2 to 5.53.1
2026-02-25 12:22:06 +05:30
Alam dbbad3f5d9 Merge pull request #498 from alam00000/dependabot/npm_and_yarn/jspdf-4.2.0
build(deps): bump jspdf from 4.1.0 to 4.2.0
2026-02-25 12:21:05 +05:30
alam00000 3e38e5b175 fix: add compact mode to simple mode 2026-02-24 20:40:24 +05:30
Alam 3fb779b231 Merge pull request #504 from Stephan-P/patch-2
Update tools.json
2026-02-22 23:39:33 +05:30
Alam 988949dbf2 Merge pull request #503 from Stephan-P/patch-1
Update common.json
2026-02-22 23:39:18 +05:30
Stephan Paternotte 4f7f477898 Update common.json
interpunction corrected
2026-02-22 11:43:39 +01:00
Stephan Paternotte c94d15f305 Update tools.json 2026-02-21 10:50:58 +01:00
Stephan Paternotte 9283e89f19 Update common.json 2026-02-21 10:48:41 +01:00
alam00000 a386fd26fc fix: enhance error handling for i18n page generation 2026-02-21 14:44:09 +05:30
alam00000 32ba24782f Release v2.3.1 2026-02-21 14:27:59 +05:30
alam00000 a119837c8a fix: improve error handling in performCondenseCompression function
fix: routing
2026-02-21 14:27:53 +05:30
dependabot[bot] f7a1eef5ec build(deps): bump svelte from 5.48.2 to 5.53.1
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.48.2 to 5.53.1.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/[email protected]/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.53.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-21 08:31:36 +00:00
dependabot[bot] 7351cb2e32 build(deps): bump markdown-it from 14.1.0 to 14.1.1
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.1.0 to 14.1.1.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.1.0...14.1.1)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 14.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-21 08:30:54 +00:00
dependabot[bot] c8391d5886 build(deps): bump jspdf from 4.1.0 to 4.2.0
Bumps [jspdf](https://github.com/parallax/jsPDF) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/parallax/jsPDF/releases)
- [Changelog](https://github.com/parallax/jsPDF/blob/master/RELEASE.md)
- [Commits](https://github.com/parallax/jsPDF/compare/v4.1.0...v4.2.0)

---
updated-dependencies:
- dependency-name: jspdf
  dependency-version: 4.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-20 13:13:36 +00:00
dependabot[bot] 43d747b3c5 build(deps-dev): bump qs from 6.14.1 to 6.15.0
Bumps [qs](https://github.com/ljharb/qs) from 6.14.1 to 6.15.0.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.1...v6.15.0)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-20 11:58:48 +00:00
58 changed files with 2442 additions and 924 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ VITE_CORS_PROXY_SECRET=
# WASM Module URLs # WASM Module URLs
# Pre-configured defaults enable advanced PDF features out of the box. # Pre-configured defaults enable advanced PDF features out of the box.
# For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/). # For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/).
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/ VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
+1 -1
View File
@@ -458,7 +458,7 @@ Advanced PDF features (PyMuPDF, Ghostscript, CoherentPDF) are pre-configured to
The default URLs are set in `.env.production`: The default URLs are set in `.env.production`:
```bash ```bash
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/ VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
``` ```
+1 -1
View File
@@ -28,7 +28,7 @@ npx wrangler secret put CPDF_SOURCE -c wasm-wrangler.toml
**Recommended Source URLs:** **Recommended Source URLs:**
- PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/` - PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/`
- GS_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` - GS_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/`
- CPDF_SOURCE: `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` - CPDF_SOURCE: `https://cdn.jsdelivr.net/npm/coherentpdf/dist/`
+1 -1
View File
@@ -92,7 +92,7 @@ docker run -d -p 3000:8080 bentopdf:custom
| ----------------------- | ------------------------------- | -------------------------------------------------------------- | | ----------------------- | ------------------------------- | -------------------------------------------------------------- |
| `SIMPLE_MODE` | Build without LibreOffice tools | `false` | | `SIMPLE_MODE` | Build without LibreOffice tools | `false` |
| `BASE_URL` | Deploy to subdirectory | `/` | | `BASE_URL` | Deploy to subdirectory | `/` |
| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/` | | `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/` |
| `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` | | `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` |
| `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` | | `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` |
| `VITE_DEFAULT_LANGUAGE` | Default UI language | `en` | | `VITE_DEFAULT_LANGUAGE` | Default UI language | `en` |
+1 -1
View File
@@ -167,7 +167,7 @@ As of v2.0.0, WASM modules are pre-configured to load from jsDelivr CDN via envi
These are set in `.env.production` and baked into the build: These are set in `.env.production` and baked into the build:
```bash ```bash
VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/ VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/
VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/
VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/
``` ```
+15 -16
View File
@@ -27,6 +27,21 @@ http {
index index.html; index index.html;
absolute_redirect off; absolute_redirect off;
location ~ ^/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~ ^/(.+?)/(en|ar|be|da|de|es|fr|id|it|nl|pt|tr|vi|zh|zh-TW)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~* \.html$ { location ~* \.html$ {
expires 1h; expires 1h;
@@ -84,22 +99,6 @@ http {
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
} }
location ~ ^/(en|de|es|zh|zh-TW|vi|it|id|tr|fr|pt|be)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/index.html /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location ~ ^/(.+?)/(en|de|es|zh|zh-TW|vi|it|id|tr|fr|pt|be)(/.*)?$ {
try_files $uri $uri/ $uri.html /$1/$2/index.html /$1/index.html /index.html;
expires 5m;
add_header Cache-Control "public, must-revalidate";
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
}
location / { location / {
try_files $uri $uri/ $uri.html /index.html; try_files $uri $uri/ $uri.html /index.html;
expires 5m; expires 5m;
+24 -23
View File
@@ -36,12 +36,12 @@
"i18next": "^25.7.2", "i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"jspdf": "^4.1.0", "jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lit": "^3.3.2", "lit": "^3.3.2",
"lucide": "^0.546.0", "lucide": "^0.546.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.1",
"markdown-it-abbr": "^2.0.0", "markdown-it-abbr": "^2.0.0",
"markdown-it-anchor": "^9.2.0", "markdown-it-anchor": "^9.2.0",
"markdown-it-deflist": "^3.0.0", "markdown-it-deflist": "^3.0.0",
@@ -480,9 +480,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.28.4", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -6432,9 +6432,9 @@
} }
}, },
"node_modules/devalue": { "node_modules/devalue": {
"version": "5.6.2", "version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==",
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
@@ -8228,12 +8228,12 @@
} }
}, },
"node_modules/jspdf": { "node_modules/jspdf": {
"version": "4.1.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
"integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==", "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.28.4", "@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0", "fast-png": "^6.2.0",
"fflate": "^0.8.1" "fflate": "^0.8.1"
}, },
@@ -9050,9 +9050,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/markdown-it": { "node_modules/markdown-it": {
"version": "14.1.0", "version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1", "argparse": "^2.0.1",
@@ -10309,9 +10309,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -11442,9 +11442,9 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.48.2", "version": "5.53.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.2.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.1.tgz",
"integrity": "sha512-VPWD+UyoSFZ7Nxix5K/F8yWiKWOiROkLlWYXOZReE0TUycw+58YWB3D6lAKT+57xmN99wRX4H3oZmw0NPy7y3Q==", "integrity": "sha512-WzxFHZhhD23Qzu7JCYdvm1rxvRSzdt9HtHO8TScMBX51bLRFTcJmATVqjqXG+6Ln6hrViGCo9DzwOhAasxwC/w==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -11452,13 +11452,14 @@
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5", "@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5", "@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1", "acorn": "^8.12.1",
"aria-query": "^5.3.1", "aria-query": "^5.3.1",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"devalue": "^5.6.2", "devalue": "^5.6.3",
"esm-env": "^1.2.1", "esm-env": "^1.2.1",
"esrap": "^2.2.1", "esrap": "^2.2.2",
"is-reference": "^3.0.3", "is-reference": "^3.0.3",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.11", "magic-string": "^0.30.11",
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "bento-pdf", "name": "bento-pdf",
"private": true, "private": true,
"version": "2.3.0", "version": "2.3.3",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -92,12 +92,12 @@
"i18next": "^25.7.2", "i18next": "^25.7.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"jspdf": "^4.1.0", "jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.2", "jspdf-autotable": "^5.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"lit": "^3.3.2", "lit": "^3.3.2",
"lucide": "^0.546.0", "lucide": "^0.546.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.1",
"markdown-it-abbr": "^2.0.0", "markdown-it-abbr": "^2.0.0",
"markdown-it-anchor": "^9.2.0", "markdown-it-anchor": "^9.2.0",
"markdown-it-deflist": "^3.0.0", "markdown-it-deflist": "^3.0.0",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "إضافة علامة مائية", "name": "إضافة علامة مائية",
"subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك." "subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك.",
"applyToAllPages": "تطبيق على جميع الصفحات"
}, },
"headerFooter": { "headerFooter": {
"name": "رأس وتذييل", "name": "رأس وتذييل",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Дадаць вадзяны знак", "name": "Дадаць вадзяны знак",
"subtitle": "Накласці на старонкі PDF тэкст або відарыс." "subtitle": "Накласці на старонкі PDF тэкст або відарыс.",
"applyToAllPages": "Прымяніць да ўсіх старонак"
}, },
"headerFooter": { "headerFooter": {
"name": "Верхні і ніжні калантытул", "name": "Верхні і ніжні калантытул",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Tilføj vandmærke", "name": "Tilføj vandmærke",
"subtitle": "Placer tekst eller et billede oven på dine PDF-sider." "subtitle": "Placer tekst eller et billede oven på dine PDF-sider.",
"applyToAllPages": "Anvend på alle sider"
}, },
"headerFooter": { "headerFooter": {
"name": "Sidehoved og sidefod", "name": "Sidehoved og sidefod",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Wasserzeichen hinzufügen", "name": "Wasserzeichen hinzufügen",
"subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln." "subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln.",
"applyToAllPages": "Auf alle Seiten anwenden"
}, },
"headerFooter": { "headerFooter": {
"name": "Kopf- & Fußzeile", "name": "Kopf- & Fußzeile",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Add Watermark", "name": "Add Watermark",
"subtitle": "Stamp text or an image over your PDF pages." "subtitle": "Stamp text or an image over your PDF pages.",
"applyToAllPages": "Apply to all pages"
}, },
"headerFooter": { "headerFooter": {
"name": "Header & Footer", "name": "Header & Footer",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Agregar Marca de Agua", "name": "Agregar Marca de Agua",
"subtitle": "Estampa texto o una imagen sobre tus páginas PDF." "subtitle": "Estampa texto o una imagen sobre tus páginas PDF.",
"applyToAllPages": "Aplicar a todas las páginas"
}, },
"headerFooter": { "headerFooter": {
"name": "Encabezado y Pie de Página", "name": "Encabezado y Pie de Página",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Ajouter un filigrane", "name": "Ajouter un filigrane",
"subtitle": "Apposer un texte ou une image sur les pages du PDF." "subtitle": "Apposer un texte ou une image sur les pages du PDF.",
"applyToAllPages": "Appliquer à toutes les pages"
}, },
"headerFooter": { "headerFooter": {
"name": "En-tête et pied de page", "name": "En-tête et pied de page",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Tambah Watermark", "name": "Tambah Watermark",
"subtitle": "Cap teks atau gambar di atas halaman PDF Anda." "subtitle": "Cap teks atau gambar di atas halaman PDF Anda.",
"applyToAllPages": "Terapkan ke semua halaman"
}, },
"headerFooter": { "headerFooter": {
"name": "Header & Footer", "name": "Header & Footer",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Aggiungi Filigrana", "name": "Aggiungi Filigrana",
"subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF." "subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF.",
"applyToAllPages": "Applica a tutte le pagine"
}, },
"headerFooter": { "headerFooter": {
"name": "Intestazione e Piè di Pagina", "name": "Intestazione e Piè di Pagina",
+13 -12
View File
@@ -56,7 +56,7 @@
"title": "Aan de slag met", "title": "Aan de slag met",
"toolsLabel": "Tools", "toolsLabel": "Tools",
"subtitle": "Klik een tool om de bestandslader te openen", "subtitle": "Klik een tool om de bestandslader te openen",
"searchPlaceholder": "Zoek een tool (bijv., 'splitsen', 'organiseren'...)", "searchPlaceholder": "Zoek een tool (bijv. 'splitsen', 'organiseren', ...)",
"backToTools": "Terug naar Tools", "backToTools": "Terug naar Tools",
"firstLoadNotice": "De eerste keer duurt het even om onze conversiemachine te laden. Daarna gaat alles meteen." "firstLoadNotice": "De eerste keer duurt het even om onze conversiemachine te laden. Daarna gaat alles meteen."
}, },
@@ -95,6 +95,15 @@
"webpImages": "WebP-afbeeldingen" "webpImages": "WebP-afbeeldingen"
} }
}, },
"howItWorks": {
"title": "Hoe het werkt",
"step1": "Klik of sleep uw bestand hierheen",
"step2": "Klik op de verwerkingsknop",
"step3": "Sla uw verwerkte bestand direct op"
},
"relatedTools": {
"title": "Gerelateerde PDF-tools"
},
"loader": { "loader": {
"processing": "Verwerken..." "processing": "Verwerken..."
}, },
@@ -166,6 +175,7 @@
"faq": { "faq": {
"title": "Veelgestelde", "title": "Veelgestelde",
"questions": "Vragen", "questions": "Vragen",
"sectionTitle": "Veelgestelde vragen",
"isFree": { "isFree": {
"question": "Is BentoPDF echt gratis?", "question": "Is BentoPDF echt gratis?",
"answer": "Ja, absoluut. Alle tools op BentoPDF zijn 100% gratis te gebruiken, zonder bestandslimieten, zonder aanmeldingen en zonder watermerken. Wij vinden dat iedereen toegang verdient tot eenvoudige, krachtige PDF-tools zonder betaalmuur." "answer": "Ja, absoluut. Alle tools op BentoPDF zijn 100% gratis te gebruiken, zonder bestandslimieten, zonder aanmeldingen en zonder watermerken. Wij vinden dat iedereen toegang verdient tot eenvoudige, krachtige PDF-tools zonder betaalmuur."
@@ -197,8 +207,7 @@
"analytics": { "analytics": {
"question": "Gebruikt BentoPDF cookies of analytics om mij te volgen?", "question": "Gebruikt BentoPDF cookies of analytics om mij te volgen?",
"answer": "We geven om je privacy. BentoPDF houdt geen persoonlijke gegevens bij. We gebruiken Simple Analytics alleen om anonieme bezoekersaantallen te zien. Dit betekent dat we kunnen weten hoeveel mensen onze site bezoeken, maar we weten nooit wie jij bent. Simple Analytics is volledig AVG-conform en respecteert je privacy." "answer": "We geven om je privacy. BentoPDF houdt geen persoonlijke gegevens bij. We gebruiken Simple Analytics alleen om anonieme bezoekersaantallen te zien. Dit betekent dat we kunnen weten hoeveel mensen onze site bezoeken, maar we weten nooit wie jij bent. Simple Analytics is volledig AVG-conform en respecteert je privacy."
}, }
"sectionTitle": "Veelgestelde vragen"
}, },
"testimonials": { "testimonials": {
"title": "Wat onze", "title": "Wat onze",
@@ -349,17 +358,9 @@
"error": "Fout", "error": "Fout",
"failedToLoad": "Laden is mislukt" "failedToLoad": "Laden is mislukt"
}, },
"howItWorks": {
"title": "Hoe het werkt",
"step1": "Klik of sleep uw bestand hierheen",
"step2": "Klik op de verwerkingsknop",
"step3": "Sla uw verwerkte bestand direct op"
},
"relatedTools": {
"title": "Gerelateerde PDF-tools"
},
"simpleMode": { "simpleMode": {
"title": "PDF-tools", "title": "PDF-tools",
"subtitle": "Selecteer een tool om te beginnen" "subtitle": "Selecteer een tool om te beginnen"
} }
} }
+3 -2
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Watermerk toevoegen", "name": "Watermerk toevoegen",
"subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen." "subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen.",
"applyToAllPages": "Toepassen op alle pagina's"
}, },
"headerFooter": { "headerFooter": {
"name": "Koptekst & Voettekst", "name": "Koptekst & Voettekst",
@@ -617,7 +618,7 @@
"workflowCompleted": "Werkproces voltooid", "workflowCompleted": "Werkproces voltooid",
"errorDuringExecution": "Fout tijdens uitvoering", "errorDuringExecution": "Fout tijdens uitvoering",
"addNodeError": "Voeg minimaal één knooppunt toe om het werkproces uit te voeren.", "addNodeError": "Voeg minimaal één knooppunt toe om het werkproces uit te voeren.",
"needInputOutput": "Om te kunnen functioneren heeft een werkproces heeft tenminste een invoerknoopunt en een uitvoer-knooppunt nodig.", "needInputOutput": "Het werkproces heeft tenminste een invoerknoopunt en een uitvoer-knooppunt nodig.",
"enterName": "Voer een naam in.", "enterName": "Voer een naam in.",
"templateExists": "Er bestaat al een sjabloon met deze naam.", "templateExists": "Er bestaat al een sjabloon met deze naam.",
"templateSaved": "Sjabloon \"{{name}}\" opgeslagen.", "templateSaved": "Sjabloon \"{{name}}\" opgeslagen.",
+130 -129
View File
@@ -70,7 +70,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Adicionar Marca d'Água", "name": "Adicionar Marca d'Água",
"subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF." "subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF.",
"applyToAllPages": "Aplicar a todas as páginas"
}, },
"headerFooter": { "headerFooter": {
"name": "Cabeçalho e Rodapé", "name": "Cabeçalho e Rodapé",
@@ -207,7 +208,7 @@
"subtitle": "Converta arquivos PDF para o formato JSON." "subtitle": "Converta arquivos PDF para o formato JSON."
}, },
"ocrPdf": { "ocrPdf": {
"name": "OCR PDF", "name": "OCR para PDF",
"subtitle": "Torne um PDF pesquisável e copiável (reconhecimento de texto)." "subtitle": "Torne um PDF pesquisável e copiável (reconhecimento de texto)."
}, },
"alternateMix": { "alternateMix": {
@@ -329,192 +330,192 @@
"subtitle": "Endireite automaticamente páginas digitalizadas inclinadas usando OpenCV." "subtitle": "Endireite automaticamente páginas digitalizadas inclinadas usando OpenCV."
}, },
"rotateCustom": { "rotateCustom": {
"name": "Rotate by Custom Degrees", "name": "Rotacionar em Graus Customizáveis",
"subtitle": "Rotate pages by any custom angle." "subtitle": "Rotaciona páginas em qualquer ângulo customizado."
}, },
"odtToPdf": { "odtToPdf": {
"name": "ODT to PDF", "name": "ODT para PDF",
"subtitle": "Convert OpenDocument Text files to PDF format. Supports multiple files.", "subtitle": "Converte arquivos em formato OpenDocument Text para PDF. Suporta multiplos arquivos.",
"acceptedFormats": "ODT files", "acceptedFormats": "Arquivos ODT",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"csvToPdf": { "csvToPdf": {
"name": "CSV to PDF", "name": "CSV para PDF",
"subtitle": "Convert CSV spreadsheet files to PDF format. Supports multiple files.", "subtitle": "Converte arquivos de planilhas CSV para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "CSV files", "acceptedFormats": "Arquivos CSV",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"rtfToPdf": { "rtfToPdf": {
"name": "RTF to PDF", "name": "RTF para PDF",
"subtitle": "Convert Rich Text Format documents to PDF. Supports multiple files.", "subtitle": "Converte documentos Rich Text Format para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "RTF files", "acceptedFormats": "Arquivos RTF",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"wordToPdf": { "wordToPdf": {
"name": "Word to PDF", "name": "Word para PDF",
"subtitle": "Convert Word documents (DOCX, DOC, ODT, RTF) to PDF format. Supports multiple files.", "subtitle": "Converte documentos Word documents (DOCX, DOC, ODT, RTF) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "DOCX, DOC, ODT, RTF files", "acceptedFormats": "Arquivos DOCX, DOC, ODT, RTF",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"excelToPdf": { "excelToPdf": {
"name": "Excel to PDF", "name": "Excel para PDF",
"subtitle": "Convert Excel spreadsheets (XLSX, XLS, ODS, CSV) to PDF format. Supports multiple files.", "subtitle": "Converte planilhas Excel (XLSX, XLS, ODS, CSV) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "XLSX, XLS, ODS, CSV files", "acceptedFormats": "Arquivos XLSX, XLS, ODS, CSV",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"powerpointToPdf": { "powerpointToPdf": {
"name": "PowerPoint to PDF", "name": "PowerPoint para PDF",
"subtitle": "Convert PowerPoint presentations (PPTX, PPT, ODP) to PDF format. Supports multiple files.", "subtitle": "Converte apresentações PowerPoint (PPTX, PPT, ODP) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "PPTX, PPT, ODP files", "acceptedFormats": "Arquivos PPTX, PPT, ODP",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"markdownToPdf": { "markdownToPdf": {
"name": "Markdown to PDF", "name": "Markdown para PDF",
"subtitle": "Write or paste Markdown and export it as a beautifully formatted PDF.", "subtitle": "Escreva ou cole Markdown e exporte como um belo formato PDF.",
"paneMarkdown": "Markdown", "paneMarkdown": "Markdown",
"panePreview": "Preview", "panePreview": "Pré-visualizar",
"btnUpload": "Upload", "btnUpload": "Upload",
"btnSyncScroll": "Sync Scroll", "btnSyncScroll": "Sincronizar Scroll",
"btnSettings": "Settings", "btnSettings": "Configurações",
"btnExportPdf": "Export PDF", "btnExportPdf": "Exportar PDF",
"settingsTitle": "Markdown Settings", "settingsTitle": "Configurações Markdown",
"settingsPreset": "Preset", "settingsPreset": "Predefinição",
"presetDefault": "Default (GFM-like)", "presetDefault": "Padrão (tipo GFM)",
"presetCommonmark": "CommonMark (strict)", "presetCommonmark": "CommonMark (extrito)",
"presetZero": "Minimal (no features)", "presetZero": "Mínimo (sem features)",
"settingsOptions": "Markdown Options", "settingsOptions": "Opções de Markdown",
"optAllowHtml": "Allow HTML tags", "optAllowHtml": "Permitir tags HTML",
"optBreaks": "Convert newlines to <br>", "optBreaks": "Converter novas linhas em <br>",
"optLinkify": "Auto-convert URLs to links", "optLinkify": "Auto-converter URLs para links",
"optTypographer": "Typographer (smart quotes, etc.)" "optTypographer": "Tipógrafo (aspas inteligentes, etc.)"
}, },
"pdfBooklet": { "pdfBooklet": {
"name": "PDF Booklet", "name": "PDF Booklet",
"subtitle": "Rearrange pages for double-sided booklet printing. Fold and staple to create a booklet.", "subtitle": "Reordena páginas para impressão de livreto frente e verso. Dobre e grampeie para criar um livreto.",
"howItWorks": "How it works:", "howItWorks": "Como funciona:",
"step1": "Upload a PDF file.", "step1": "Faça o upload de um arquivo PDF.",
"step2": "Pages will be rearranged in booklet order.", "step2": "As páginas serão reordenadas na ordem de livretos.",
"step3": "Print double-sided, flip on short edge, fold and staple.", "step3": "Imprima frente e verso, vire pelo topo, dobre e grampeie.",
"paperSize": "Paper Size", "paperSize": "Tamanho do Papel",
"orientation": "Orientation", "orientation": "Orientação",
"portrait": "Portrait", "portrait": "Retrato",
"landscape": "Landscape", "landscape": "Paisagem",
"pagesPerSheet": "Pages per Sheet", "pagesPerSheet": "Páginas por Folha",
"createBooklet": "Create Booklet", "createBooklet": "Criar Livreto",
"processing": "Processing...", "processing": "Processando...",
"pageCount": "Page count will be padded to multiple of 4 if needed." "pageCount": "A contagem de páginas será ajustada para um múltiplo de 4, caso necessário."
}, },
"xpsToPdf": { "xpsToPdf": {
"name": "XPS to PDF", "name": "XPS para PDF",
"subtitle": "Convert XPS/OXPS documents to PDF format. Supports multiple files.", "subtitle": "Converte XPS/OXPS para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "XPS, OXPS files", "acceptedFormats": "Arquivos XPS, OXPS",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"mobiToPdf": { "mobiToPdf": {
"name": "MOBI to PDF", "name": "MOBI para PDF",
"subtitle": "Convert MOBI e-books to PDF format. Supports multiple files.", "subtitle": "Converte e-books MOBI para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "MOBI files", "acceptedFormats": "Arquivos MOBI",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"epubToPdf": { "epubToPdf": {
"name": "EPUB to PDF", "name": "EPUB para PDF",
"subtitle": "Convert EPUB e-books to PDF format. Supports multiple files.", "subtitle": "Converte e-books EPUB para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "EPUB files", "acceptedFormats": "Arquivos EPUB",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"fb2ToPdf": { "fb2ToPdf": {
"name": "FB2 to PDF", "name": "FB2 para PDF",
"subtitle": "Convert FictionBook (FB2) e-books to PDF format. Supports multiple files.", "subtitle": "Converte e-books FictionBook (FB2) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "FB2 files", "acceptedFormats": "Arquivos FB2",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"cbzToPdf": { "cbzToPdf": {
"name": "CBZ to PDF", "name": "CBZ para PDF",
"subtitle": "Convert comic book archives (CBZ/CBR) to PDF format. Supports multiple files.", "subtitle": "Converte comic book archives (CBZ/CBR) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "CBZ, CBR files", "acceptedFormats": "Arquivos CBZ, CBR",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"wpdToPdf": { "wpdToPdf": {
"name": "WPD to PDF", "name": "WPD para PDF",
"subtitle": "Convert WordPerfect documents (WPD) to PDF format. Supports multiple files.", "subtitle": "Converte WordPerfect (WPD) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "WPD files", "acceptedFormats": "Arquivos WPD",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"wpsToPdf": { "wpsToPdf": {
"name": "WPS to PDF", "name": "WPS para PDF",
"subtitle": "Convert WPS Office documents to PDF format. Supports multiple files.", "subtitle": "Converte WPS Office para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "WPS files", "acceptedFormats": "Arquivos WPS",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"xmlToPdf": { "xmlToPdf": {
"name": "XML to PDF", "name": "XML para PDF",
"subtitle": "Convert XML documents to PDF format. Supports multiple files.", "subtitle": "Converte XML para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "XML files", "acceptedFormats": "Arquivos XML",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"pagesToPdf": { "pagesToPdf": {
"name": "Pages to PDF", "name": "Pages para PDF",
"subtitle": "Convert Apple Pages documents to PDF format. Supports multiple files.", "subtitle": "Converte Apple Pages para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "Pages files", "acceptedFormats": "Arquivos Pages",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"odgToPdf": { "odgToPdf": {
"name": "ODG to PDF", "name": "ODG para PDF",
"subtitle": "Convert OpenDocument Graphics (ODG) files to PDF format. Supports multiple files.", "subtitle": "Converte OpenDocument Graphics (ODG) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "ODG files", "acceptedFormats": "Arquivos ODG",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"odsToPdf": { "odsToPdf": {
"name": "ODS to PDF", "name": "ODS para PDF",
"subtitle": "Convert OpenDocument Spreadsheet (ODS) files to PDF format. Supports multiple files.", "subtitle": "Converte OpenDocument Spreadsheet (ODS) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "ODS files", "acceptedFormats": "Arquivos ODS",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"odpToPdf": { "odpToPdf": {
"name": "ODP to PDF", "name": "ODP para PDF",
"subtitle": "Convert OpenDocument Presentation (ODP) files to PDF format. Supports multiple files.", "subtitle": "Converte OpenDocument Presentation (ODP) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "ODP files", "acceptedFormats": "Arquivos ODP",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"pubToPdf": { "pubToPdf": {
"name": "PUB to PDF", "name": "PUB para PDF",
"subtitle": "Convert Microsoft Publisher (PUB) files to PDF format. Supports multiple files.", "subtitle": "Converte Microsoft Publisher (PUB) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "PUB files", "acceptedFormats": "Arquivos PUB",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"vsdToPdf": { "vsdToPdf": {
"name": "VSD to PDF", "name": "VSD para PDF",
"subtitle": "Convert Microsoft Visio (VSD, VSDX) files to PDF format. Supports multiple files.", "subtitle": "Converte Microsoft Visio (VSD, VSDX) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "VSD, VSDX files", "acceptedFormats": "Arquivos VSD, VSDX",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"psdToPdf": { "psdToPdf": {
"name": "PSD to PDF", "name": "PSD para PDF",
"subtitle": "Convert Adobe Photoshop (PSD) files to PDF format. Supports multiple files.", "subtitle": "Converte Adobe Photoshop (PSD) para PDF. Suporta múltiplos arquivos.",
"acceptedFormats": "PSD files", "acceptedFormats": "Arquivos PSD",
"convertButton": "Convert to PDF" "convertButton": "Converter para PDF"
}, },
"pdfToSvg": { "pdfToSvg": {
"name": "PDF to SVG", "name": "PDF para SVG",
"subtitle": "Convert each page of a PDF file into a scalable vector graphic (SVG) for perfect quality at any size." "subtitle": "Converte cada página de um arquivo PDF em um scalable vector graphic (SVG) para qualidade perfeita em qualquer tamanho."
}, },
"extractTables": { "extractTables": {
"name": "Extract PDF Tables", "name": "Extrair Tabelas PDF",
"subtitle": "Extract tables from PDF files and export as CSV, JSON, or Markdown." "subtitle": "Extrai tabelas de arquivos PDF e exporta como CSV, JSON, ou Markdown."
}, },
"pdfToCsv": { "pdfToCsv": {
"name": "PDF to CSV", "name": "PDF para CSV",
"subtitle": "Extract tables from PDF and convert to CSV format." "subtitle": "Extrai tabelas do PDF e converte em CSV."
}, },
"pdfToExcel": { "pdfToExcel": {
"name": "PDF to Excel", "name": "PDF para Excel",
"subtitle": "Extract tables from PDF and convert to Excel (XLSX) format." "subtitle": "Extrai tabelas do PDF e converte em Excel (XLSX)."
}, },
"pdfToText": { "pdfToText": {
"name": "PDF to Text", "name": "PDF para Texto",
"subtitle": "Extract text from PDF files and save as plain text (.txt). Supports multiple files.", "subtitle": "Extrai texto de arquivos PDF e salva como texto simples (.txt). Suporta múltiplos arquivos.",
"note": "This tool works ONLY with digitally created PDFs. For scanned documents or image-based PDFs, use our OCR PDF tool instead.", "note": "Esta ferrament funciona SOMENTE com PDFs criados digitalmente. Para documentos escaneados ou PDFs baseados em imagens, use nossa ferramenta OCR para PDF no lugar.",
"convertButton": "Extract Text" "convertButton": "Extrair Texto"
}, },
"digitalSignPdf": { "digitalSignPdf": {
"name": "Assinatura Digital PDF", "name": "Assinatura Digital PDF",
+2 -1
View File
@@ -70,7 +70,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Filigran Ekle", "name": "Filigran Ekle",
"subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin." "subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin.",
"applyToAllPages": "Tüm sayfalara uygula"
}, },
"headerFooter": { "headerFooter": {
"name": "Üst Bilgi & Alt Bilgi", "name": "Üst Bilgi & Alt Bilgi",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "Thêm Watermark", "name": "Thêm Watermark",
"subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn." "subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn.",
"applyToAllPages": "Áp dụng cho tất cả các trang"
}, },
"headerFooter": { "headerFooter": {
"name": "Đầu trang & Chân trang", "name": "Đầu trang & Chân trang",
+2 -1
View File
@@ -70,7 +70,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "添加浮水印", "name": "添加浮水印",
"subtitle": "在你的 PDF 頁面上壓印文字或圖片。" "subtitle": "在你的 PDF 頁面上壓印文字或圖片。",
"applyToAllPages": "套用至所有頁面"
}, },
"headerFooter": { "headerFooter": {
"name": "頁首與頁尾", "name": "頁首與頁尾",
+2 -1
View File
@@ -92,7 +92,8 @@
}, },
"addWatermark": { "addWatermark": {
"name": "添加水印", "name": "添加水印",
"subtitle": "在您的 PDF 页面上添加文字或图片水印。" "subtitle": "在您的 PDF 页面上添加文字或图片水印。",
"applyToAllPages": "应用到所有页面"
}, },
"headerFooter": { "headerFooter": {
"name": "页眉和页脚", "name": "页眉和页脚",
+4 -1
View File
@@ -269,4 +269,7 @@ async function generateI18nPages() {
console.log('✅ i18n pages generated successfully!'); console.log('✅ i18n pages generated successfully!');
} }
generateI18nPages().catch(console.error); generateI18nPages().catch((err) => {
console.error('❌ i18n page generation failed:', err);
process.exit(1);
});
+8
View File
@@ -327,6 +327,14 @@
"created_at": "2026-02-21T00:26:29Z", "created_at": "2026-02-21T00:26:29Z",
"repoId": 1074785178, "repoId": 1074785178,
"pullRequestNo": 501 "pullRequestNo": 501
},
{
"name": "hagibr",
"id": 23699887,
"comment_id": 3960964454,
"created_at": "2026-02-25T17:50:30Z",
"repoId": 1074785178,
"pullRequestNo": 516
} }
] ]
} }
+32
View File
@@ -332,6 +332,38 @@
</label> </label>
</div> </div>
<div
class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700"
>
<div class="flex-1">
<label
for="compact-mode-toggle"
class="text-sm font-medium text-gray-200 cursor-pointer"
data-i18n="settings.compactMode"
>
Compact Mode
</label>
<p
class="text-xs text-gray-400 mt-1"
data-i18n="settings.compactModeDescription"
>
Display tools as a compact list instead of cards
</p>
</div>
<label
class="relative inline-flex items-center cursor-pointer ml-4"
>
<input
type="checkbox"
id="compact-mode-toggle"
class="sr-only peer"
/>
<div
class="w-11 h-6 bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"
></div>
</label>
</div>
<a <a
href="wasm-settings.html" href="wasm-settings.html"
class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700 hover:bg-gray-800 hover:border-indigo-500/50 transition-all group" class="flex items-center justify-between p-4 bg-gray-900 rounded-lg border border-gray-700 hover:bg-gray-800 hover:border-indigo-500/50 transition-all group"
+27
View File
@@ -701,6 +701,33 @@ button:disabled,
pointer-events: none; pointer-events: none;
} }
/* Color picker */
input[type='color'] {
width: 2.5rem;
height: 2.5rem;
padding: 0;
border: 2px solid #4b5563;
border-radius: 9999px;
background: transparent;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
input[type='color']::-webkit-color-swatch-wrapper {
padding: 0;
}
input[type='color']::-webkit-color-swatch {
border: none;
border-radius: 9999px;
}
input[type='color']::-moz-color-swatch {
border: none;
border-radius: 9999px;
}
/* Hide spin buttons for number inputs */ /* Hide spin buttons for number inputs */
input[type='number'] { input[type='number'] {
appearance: none; appearance: none;
+1 -1
View File
@@ -1,4 +1,4 @@
export const PACKAGE_VERSIONS = { export const PACKAGE_VERSIONS = {
ghostscript: '0.1.1', ghostscript: '0.1.1',
pymupdf: '0.11.14', pymupdf: '0.11.16',
} as const; } as const;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -253,7 +253,7 @@ placeholder="${field.placeholder || ''}" />
) )
.join('')} .join('')}
</select> </select>
${field.name === 'color' ? '<input type="color" id="modal-color-picker" class="hidden w-full h-10 mt-2 rounded cursor-pointer border border-gray-300" value="#000000" />' : ''} ${field.name === 'color' ? '<input type="color" id="modal-color-picker" class="hidden mt-2" value="#000000" />' : ''}
</div> </div>
`; `;
} else if (field.type === 'destination') { } else if (field.type === 'destination') {
+2 -2
View File
@@ -931,7 +931,7 @@ function showProperties(field: FormField): void {
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Text Color</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Text Color</label>
<input type="color" id="propTextColor" value="${field.textColor}" class="w-full border border-gray-500 rounded px-2 py-1 h-10"> <input type="color" id="propTextColor" value="${field.textColor}">
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Alignment</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Alignment</label>
@@ -1169,7 +1169,7 @@ function showProperties(field: FormField): void {
</div> </div>
<div> <div>
<label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label> <label class="block text-xs font-semibold text-gray-300 mb-1">Border Color</label>
<input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}" class="w-full border border-gray-500 rounded px-2 py-1 h-10"> <input type="color" id="propBorderColor" value="${field.borderColor || '#000000'}">
</div> </div>
<div class="flex items-center"> <div class="flex items-center">
<input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2"> <input type="checkbox" id="propHideBorder" ${field.hideBorder ? 'checked' : ''} class="mr-2">
+396 -130
View File
@@ -1,10 +1,15 @@
import { createIcons, icons } from 'lucide'; import { createIcons, icons } from 'lucide';
import { degrees, PDFDocument as PDFLibDocument } from 'pdf-lib'; import { degrees, PDFDocument as PDFLibDocument, PDFPage } from 'pdf-lib';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import JSZip from 'jszip'; import JSZip from 'jszip';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import { downloadFile, getPDFDocument } from '../utils/helpers'; import { downloadFile, getPDFDocument } from '../utils/helpers';
import { renderPagesProgressively, cleanupLazyRendering, renderPageToCanvas, createPlaceholder } from '../utils/render-utils'; import {
renderPagesProgressively,
cleanupLazyRendering,
renderPageToCanvas,
createPlaceholder,
} from '../utils/render-utils';
import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js';
import { repairPdfFile } from './repair-pdf.js'; import { repairPdfFile } from './repair-pdf.js';
@@ -41,13 +46,17 @@ let sortableInstance: Sortable | null = null;
const pageCanvasCache = new Map<string, HTMLCanvasElement>(); const pageCanvasCache = new Map<string, HTMLCanvasElement>();
type Snapshot = { allPages: PageData[]; selectedPages: number[]; splitMarkers: number[] }; type Snapshot = {
allPages: PageData[];
selectedPages: number[];
splitMarkers: number[];
};
const undoStack: Snapshot[] = []; const undoStack: Snapshot[] = [];
const redoStack: Snapshot[] = []; const redoStack: Snapshot[] = [];
function snapshot() { function snapshot() {
const snap: Snapshot = { const snap: Snapshot = {
allPages: allPages.map(p => ({ ...p, canvas: p.canvas })), allPages: allPages.map((p) => ({ ...p, canvas: p.canvas })),
selectedPages: Array.from(selectedPages), selectedPages: Array.from(selectedPages),
splitMarkers: Array.from(splitMarkers), splitMarkers: Array.from(splitMarkers),
}; };
@@ -56,16 +65,20 @@ function snapshot() {
} }
function restore(snap: Snapshot) { function restore(snap: Snapshot) {
allPages = snap.allPages.map(p => ({ allPages = snap.allPages.map((p) => ({
...p, ...p,
canvas: p.canvas canvas: p.canvas,
})); }));
selectedPages = new Set(snap.selectedPages); selectedPages = new Set(snap.selectedPages);
splitMarkers = new Set(snap.splitMarkers); splitMarkers = new Set(snap.splitMarkers);
updatePageDisplay(); updatePageDisplay();
} }
function showModal(title: string, message: string, type: 'info' | 'error' | 'success' = 'info') { function showModal(
title: string,
message: string,
type: 'info' | 'error' | 'success' = 'info'
) {
const modal = document.getElementById('modal'); const modal = document.getElementById('modal');
const modalTitle = document.getElementById('modal-title'); const modalTitle = document.getElementById('modal-title');
const modalMessage = document.getElementById('modal-message'); const modalMessage = document.getElementById('modal-message');
@@ -79,12 +92,12 @@ function showModal(title: string, message: string, type: 'info' | 'error' | 'suc
const iconMap = { const iconMap = {
info: 'info', info: 'info',
error: 'alert-circle', error: 'alert-circle',
success: 'check-circle' success: 'check-circle',
}; };
const colorMap = { const colorMap = {
info: 'text-blue-400', info: 'text-blue-400',
error: 'text-red-400', error: 'text-red-400',
success: 'text-green-400' success: 'text-green-400',
}; };
modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`; modalIcon.innerHTML = `<i data-lucide="${iconMap[type]}" class="w-12 h-12 ${colorMap[type]}"></i>`;
@@ -112,7 +125,10 @@ function showLoading(current: number, total: number) {
text.textContent = t('multiTool.renderingPages'); text.textContent = t('multiTool.renderingPages');
} }
async function withButtonLoading(buttonId: string, action: () => Promise<void>) { async function withButtonLoading(
buttonId: string,
action: () => Promise<void>
) {
const button = document.getElementById(buttonId) as HTMLButtonElement; const button = document.getElementById(buttonId) as HTMLButtonElement;
if (!button) return; if (!button) return;
@@ -122,7 +138,8 @@ async function withButtonLoading(buttonId: string, action: () => Promise<void>)
try { try {
button.disabled = true; button.disabled = true;
button.style.pointerEvents = 'none'; button.style.pointerEvents = 'none';
button.innerHTML = '<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>'; button.innerHTML =
'<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
await action(); await action();
} finally { } finally {
@@ -143,7 +160,9 @@ if (document.readyState === 'loading') {
initializeTool(); initializeTool();
}); });
} else { } else {
console.log('PDF Multi Tool: DOMContentLoaded already fired, initializing immediately'); console.log(
'PDF Multi Tool: DOMContentLoaded already fired, initializing immediately'
);
initializeTool(); initializeTool();
} }
@@ -160,20 +179,30 @@ function initializeTool() {
document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => { document.getElementById('upload-pdfs-btn')?.addEventListener('click', () => {
console.log('Upload button clicked, isRendering:', isRendering); console.log('Upload button clicked, isRendering:', isRendering);
if (isRendering) { if (isRendering) {
showModal(t('multiTool.pleaseWait'), t('multiTool.pagesRendering'), 'info'); showModal(
t('multiTool.pleaseWait'),
t('multiTool.pagesRendering'),
'info'
);
return; return;
} }
document.getElementById('pdf-file-input')?.click(); document.getElementById('pdf-file-input')?.click();
}); });
document.getElementById('pdf-file-input')?.addEventListener('change', handlePdfUpload); document
document.getElementById('insert-pdf-input')?.addEventListener('change', handleInsertPdf); .getElementById('pdf-file-input')
?.addEventListener('change', handlePdfUpload);
document
.getElementById('insert-pdf-input')
?.addEventListener('change', handleInsertPdf);
document.getElementById('bulk-rotate-left-btn')?.addEventListener('click', () => { document
if (isRendering) return; .getElementById('bulk-rotate-left-btn')
snapshot(); ?.addEventListener('click', () => {
bulkRotate(-90); if (isRendering) return;
}); snapshot();
bulkRotate(-90);
});
document.getElementById('bulk-rotate-btn')?.addEventListener('click', () => { document.getElementById('bulk-rotate-btn')?.addEventListener('click', () => {
if (isRendering) return; if (isRendering) return;
snapshot(); snapshot();
@@ -184,27 +213,38 @@ function initializeTool() {
snapshot(); snapshot();
bulkDelete(); bulkDelete();
}); });
document.getElementById('bulk-duplicate-btn')?.addEventListener('click', () => { document
if (isRendering) return; .getElementById('bulk-duplicate-btn')
snapshot(); ?.addEventListener('click', () => {
bulkDuplicate(); if (isRendering) return;
}); snapshot();
bulkDuplicate();
});
document.getElementById('bulk-split-btn')?.addEventListener('click', () => { document.getElementById('bulk-split-btn')?.addEventListener('click', () => {
if (isRendering) return; if (isRendering) return;
snapshot(); snapshot();
bulkSplit(); bulkSplit();
}); });
document.getElementById('bulk-download-btn')?.addEventListener('click', () => { document
if (isRendering) return; .getElementById('bulk-download-btn')
if (isRendering) return; ?.addEventListener('click', () => {
if (selectedPages.size === 0) { if (isRendering) return;
showModal(t('multiTool.noPagesSelected'), t('multiTool.selectOnePage'), 'info'); if (isRendering) return;
return; if (selectedPages.size === 0) {
} showModal(
withButtonLoading('bulk-download-btn', async () => { t('multiTool.noPagesSelected'),
await downloadPagesAsPdf(Array.from(selectedPages).sort((a, b) => a - b), 'selected-pages.pdf'); t('multiTool.selectOnePage'),
'info'
);
return;
}
withButtonLoading('bulk-download-btn', async () => {
await downloadPagesAsPdf(
Array.from(selectedPages).sort((a, b) => a - b),
'selected-pages.pdf'
);
});
}); });
});
document.getElementById('select-all-btn')?.addEventListener('click', () => { document.getElementById('select-all-btn')?.addEventListener('click', () => {
if (isRendering) return; if (isRendering) return;
@@ -229,17 +269,19 @@ function initializeTool() {
await downloadAll(); await downloadAll();
}); });
}); });
document.getElementById('add-blank-page-btn')?.addEventListener('click', () => { document
if (isRendering) return; .getElementById('add-blank-page-btn')
snapshot(); ?.addEventListener('click', () => {
addBlankPage(); if (isRendering) return;
}); snapshot();
addBlankPage();
});
document.getElementById('undo-btn')?.addEventListener('click', () => { document.getElementById('undo-btn')?.addEventListener('click', () => {
if (isRendering) return; if (isRendering) return;
const last = undoStack.pop(); const last = undoStack.pop();
if (last) { if (last) {
const current: Snapshot = { const current: Snapshot = {
allPages: allPages.map(p => ({ ...p })), allPages: allPages.map((p) => ({ ...p })),
selectedPages: Array.from(selectedPages), selectedPages: Array.from(selectedPages),
splitMarkers: Array.from(splitMarkers), splitMarkers: Array.from(splitMarkers),
}; };
@@ -252,7 +294,7 @@ function initializeTool() {
const next = redoStack.pop(); const next = redoStack.pop();
if (next) { if (next) {
const current: Snapshot = { const current: Snapshot = {
allPages: allPages.map(p => ({ ...p })), allPages: allPages.map((p) => ({ ...p })),
selectedPages: Array.from(selectedPages), selectedPages: Array.from(selectedPages),
splitMarkers: Array.from(splitMarkers), splitMarkers: Array.from(splitMarkers),
}; };
@@ -269,7 +311,9 @@ function initializeTool() {
} }
}); });
document.getElementById('modal-close-btn')?.addEventListener('click', hideModal); document
.getElementById('modal-close-btn')
?.addEventListener('click', hideModal);
document.getElementById('modal')?.addEventListener('click', (e) => { document.getElementById('modal')?.addEventListener('click', (e) => {
if (e.target === document.getElementById('modal')) { if (e.target === document.getElementById('modal')) {
hideModal(); hideModal();
@@ -288,7 +332,9 @@ function initializeTool() {
uploadArea.addEventListener('drop', (e) => { uploadArea.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
uploadArea.classList.remove('border-indigo-500'); uploadArea.classList.remove('border-indigo-500');
const files = Array.from(e.dataTransfer?.files || []).filter(f => f.type === 'application/pdf'); const files = Array.from(e.dataTransfer?.files || []).filter(
(f) => f.type === 'application/pdf'
);
if (files.length > 0) { if (files.length > 0) {
loadPdfs(files); loadPdfs(files);
} }
@@ -361,27 +407,38 @@ async function loadPdfs(files: File[]) {
try { try {
console.log(`Repairing ${file.name}...`); console.log(`Repairing ${file.name}...`);
const loadingText = document.getElementById('loading-text'); const loadingText = document.getElementById('loading-text');
if (loadingText) loadingText.textContent = `Repairing ${file.name}...`; if (loadingText)
loadingText.textContent = `Repairing ${file.name}...`;
const repairedData = await repairPdfFile(file); const repairedData = await repairPdfFile(file);
if (repairedData) { if (repairedData) {
arrayBuffer = repairedData.buffer as ArrayBuffer; arrayBuffer = repairedData.buffer as ArrayBuffer;
console.log(`Successfully repaired ${file.name} before loading.`); console.log(`Successfully repaired ${file.name} before loading.`);
} else { } else {
console.warn(`Repair returned null for ${file.name}, using original file.`); console.warn(
`Repair returned null for ${file.name}, using original file.`
);
arrayBuffer = await file.arrayBuffer(); arrayBuffer = await file.arrayBuffer();
} }
} catch (repairError) { } catch (repairError) {
console.warn(`Failed to repair ${file.name}, attempting to load original:`, repairError); console.warn(
`Failed to repair ${file.name}, attempting to load original:`,
repairError
);
arrayBuffer = await file.arrayBuffer(); arrayBuffer = await file.arrayBuffer();
} }
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false }); const pdfDoc = await PDFLibDocument.load(arrayBuffer, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
currentPdfDocs.push(pdfDoc); currentPdfDocs.push(pdfDoc);
const pdfIndex = currentPdfDocs.length - 1; const pdfIndex = currentPdfDocs.length - 1;
const pdfBytes = await pdfDoc.save(); const pdfBytes = await pdfDoc.save();
const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise; const pdfjsDoc = await getPDFDocument({
data: new Uint8Array(pdfBytes),
}).promise;
const numPages = pdfjsDoc.numPages; const numPages = pdfjsDoc.numPages;
// Pre-fill allPages with placeholders to maintain order/state // Pre-fill allPages with placeholders to maintain order/state
@@ -425,10 +482,13 @@ async function loadPdfs(files: File[]) {
shouldCancel: () => renderCancelled, // Pass cancellation check shouldCancel: () => renderCancelled, // Pass cancellation check
} }
); );
} catch (e) { } catch (e) {
console.error(`Failed to load PDF ${file.name}:`, e); console.error(`Failed to load PDF ${file.name}:`, e);
showModal(t('multiTool.error'), `${t('multiTool.failedToLoad')} ${file.name}.`, 'error'); showModal(
t('multiTool.error'),
`${t('multiTool.failedToLoad')} ${file.name}.`,
'error'
);
} }
} }
@@ -439,7 +499,9 @@ async function loadPdfs(files: File[]) {
} finally { } finally {
hideLoading(); hideLoading();
isRendering = false; isRendering = false;
console.log('PDF Multi Tool: Render finished/cancelled, isRendering set to false'); console.log(
'PDF Multi Tool: Render finished/cancelled, isRendering set to false'
);
if (renderCancelled) { if (renderCancelled) {
renderCancelled = false; renderCancelled = false;
} }
@@ -464,7 +526,10 @@ function createPageCard(pageData: PageData, index: number) {
} }
// Modified to return the element instead of appending it // Modified to return the element instead of appending it
function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTMLElement { function createPageElement(
canvas: HTMLCanvasElement | null,
index: number
): HTMLElement {
const pageData = allPages[index]; const pageData = allPages[index];
if (!pageData) { if (!pageData) {
console.error(`Page data not found for index ${index}`); console.error(`Page data not found for index ${index}`);
@@ -472,7 +537,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
} }
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move'; card.className =
'bg-gray-800 rounded-lg border-2 border-gray-700 p-2 relative group cursor-move';
card.dataset.pageIndex = index.toString(); card.dataset.pageIndex = index.toString();
card.dataset.pageId = pageData.id; // Set ID for reconciliation card.dataset.pageId = pageData.id; // Set ID for reconciliation
@@ -490,7 +556,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
} }
const preview = document.createElement('div'); const preview = document.createElement('div');
preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64'; preview.className =
'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
if (canvas) { if (canvas) {
const previewCanvas = canvas; const previewCanvas = canvas;
@@ -502,7 +569,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
} else { } else {
// Show loading placeholder if canvas is null // Show loading placeholder if canvas is null
const loading = document.createElement('div'); const loading = document.createElement('div');
loading.className = 'flex flex-col items-center justify-center text-gray-400'; loading.className =
'flex flex-col items-center justify-center text-gray-400';
loading.innerHTML = ` loading.innerHTML = `
<i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i> <i data-lucide="loader" class="w-8 h-8 animate-spin mb-2"></i>
<span class="text-xs">${t('common.loading')}</span> <span class="text-xs">${t('common.loading')}</span>
@@ -518,15 +586,18 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Actions toolbar // Actions toolbar
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity absolute bottom-2 left-0 right-0'; actions.className =
'flex items-center justify-center gap-1 sm:opacity-0 group-hover:opacity-100 transition-opacity absolute bottom-2 left-0 right-0';
const actionsInner = document.createElement('div'); const actionsInner = document.createElement('div');
actionsInner.className = 'flex items-center gap-1 bg-gray-900/90 rounded px-2 py-1'; actionsInner.className =
'flex items-center gap-1 bg-gray-900/90 rounded px-2 py-1';
actions.appendChild(actionsInner); actions.appendChild(actionsInner);
// Select checkbox // Select checkbox
const selectBtn = document.createElement('button'); const selectBtn = document.createElement('button');
selectBtn.className = 'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10'; selectBtn.className =
'absolute top-2 right-2 p-1 rounded bg-gray-900/70 hover:bg-gray-800 z-10';
selectBtn.innerHTML = selectedPages.has(index) selectBtn.innerHTML = selectedPages.has(index)
? '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>' ? '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'
: '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>'; : '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
@@ -538,14 +609,16 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Rotate button // Rotate button
const rotateBtn = document.createElement('button'); const rotateBtn = document.createElement('button');
rotateBtn.className = 'p-1 rounded hover:bg-gray-700'; rotateBtn.className = 'p-1 rounded hover:bg-gray-700';
rotateBtn.innerHTML = '<i data-lucide="rotate-cw" class="w-4 h-4 text-gray-300"></i>'; rotateBtn.innerHTML =
'<i data-lucide="rotate-cw" class="w-4 h-4 text-gray-300"></i>';
rotateBtn.onclick = (e) => { rotateBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
rotatePage(index, 90); rotatePage(index, 90);
}; };
const rotateLeftBtn = document.createElement('button'); const rotateLeftBtn = document.createElement('button');
rotateLeftBtn.className = 'p-1 rounded hover:bg-gray-700'; rotateLeftBtn.className = 'p-1 rounded hover:bg-gray-700';
rotateLeftBtn.innerHTML = '<i data-lucide="rotate-ccw" class="w-4 h-4 text-gray-300"></i>'; rotateLeftBtn.innerHTML =
'<i data-lucide="rotate-ccw" class="w-4 h-4 text-gray-300"></i>';
rotateLeftBtn.onclick = (e) => { rotateLeftBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
rotatePage(index, -90); rotatePage(index, -90);
@@ -554,7 +627,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Duplicate button // Duplicate button
const duplicateBtn = document.createElement('button'); const duplicateBtn = document.createElement('button');
duplicateBtn.className = 'p-1 rounded hover:bg-gray-700'; duplicateBtn.className = 'p-1 rounded hover:bg-gray-700';
duplicateBtn.innerHTML = '<i data-lucide="copy" class="w-4 h-4 text-gray-300"></i>'; duplicateBtn.innerHTML =
'<i data-lucide="copy" class="w-4 h-4 text-gray-300"></i>';
duplicateBtn.title = t('multiTool.actions.duplicatePage'); duplicateBtn.title = t('multiTool.actions.duplicatePage');
duplicateBtn.onclick = (e) => { duplicateBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -565,7 +639,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Delete button // Delete button
const deleteBtn = document.createElement('button'); const deleteBtn = document.createElement('button');
deleteBtn.className = 'p-1 rounded hover:bg-gray-700'; deleteBtn.className = 'p-1 rounded hover:bg-gray-700';
deleteBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4 text-red-400"></i>'; deleteBtn.innerHTML =
'<i data-lucide="trash-2" class="w-4 h-4 text-red-400"></i>';
deleteBtn.title = t('multiTool.actions.deletePage'); deleteBtn.title = t('multiTool.actions.deletePage');
deleteBtn.onclick = (e) => { deleteBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -576,7 +651,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Insert PDF button // Insert PDF button
const insertBtn = document.createElement('button'); const insertBtn = document.createElement('button');
insertBtn.className = 'p-1 rounded hover:bg-gray-700'; insertBtn.className = 'p-1 rounded hover:bg-gray-700';
insertBtn.innerHTML = '<i data-lucide="file-plus" class="w-4 h-4 text-gray-300"></i>'; insertBtn.innerHTML =
'<i data-lucide="file-plus" class="w-4 h-4 text-gray-300"></i>';
insertBtn.title = t('multiTool.actions.insertPdf'); insertBtn.title = t('multiTool.actions.insertPdf');
insertBtn.onclick = (e) => { insertBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -587,7 +663,8 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
// Split button // Split button
const splitBtn = document.createElement('button'); const splitBtn = document.createElement('button');
splitBtn.className = 'p-1 rounded hover:bg-gray-700'; splitBtn.className = 'p-1 rounded hover:bg-gray-700';
splitBtn.innerHTML = '<i data-lucide="scissors" class="w-4 h-4 text-gray-300"></i>'; splitBtn.innerHTML =
'<i data-lucide="scissors" class="w-4 h-4 text-gray-300"></i>';
splitBtn.title = t('multiTool.actions.toggleSplit'); splitBtn.title = t('multiTool.actions.toggleSplit');
splitBtn.onclick = (e) => { splitBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -596,14 +673,23 @@ function createPageElement(canvas: HTMLCanvasElement | null, index: number): HTM
renderSplitMarkers(); renderSplitMarkers();
}; };
actionsInner.append(rotateLeftBtn, rotateBtn, duplicateBtn, insertBtn, splitBtn, deleteBtn); actionsInner.append(
rotateLeftBtn,
rotateBtn,
duplicateBtn,
insertBtn,
splitBtn,
deleteBtn
);
card.append(preview, info, actions, selectBtn); card.append(preview, info, actions, selectBtn);
// Check for split marker // Check for split marker
if (splitMarkers.has(index)) { if (splitMarkers.has(index)) {
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none'; marker.className =
marker.innerHTML = '<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>'; 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
marker.innerHTML =
'<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
card.appendChild(marker); card.appendChild(marker);
} }
@@ -655,15 +741,19 @@ function toggleSelectOptimized(index: number) {
const card = pagesContainer.children[index] as HTMLElement; const card = pagesContainer.children[index] as HTMLElement;
if (!card) return; if (!card) return;
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]'); const selectBtn = card.querySelector(
'button[class*="absolute top-2 right-2"]'
);
if (!selectBtn) return; if (!selectBtn) return;
if (selectedPages.has(index)) { if (selectedPages.has(index)) {
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500'); card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
selectBtn.innerHTML = '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'; selectBtn.innerHTML =
'<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
} else { } else {
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500'); card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500');
selectBtn.innerHTML = '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>'; selectBtn.innerHTML =
'<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
} }
createIcons({ icons }); createIcons({ icons });
@@ -730,7 +820,7 @@ function deletePage(index: number) {
allPages.splice(index, 1); allPages.splice(index, 1);
selectedPages.delete(index); selectedPages.delete(index);
const newSelected = new Set<number>(); const newSelected = new Set<number>();
selectedPages.forEach(i => { selectedPages.forEach((i) => {
if (i > index) newSelected.add(i - 1); if (i > index) newSelected.add(i - 1);
else if (i < index) newSelected.add(i); else if (i < index) newSelected.add(i);
}); });
@@ -759,13 +849,17 @@ async function handleInsertPdf(e: Event) {
try { try {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFLibDocument.load(arrayBuffer, { ignoreEncryption: true, throwOnInvalidObject: false }); const pdfDoc = await PDFLibDocument.load(arrayBuffer, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
currentPdfDocs.push(pdfDoc); currentPdfDocs.push(pdfDoc);
const pdfIndex = currentPdfDocs.length - 1; const pdfIndex = currentPdfDocs.length - 1;
// Load PDF.js document for rendering // Load PDF.js document for rendering
const pdfBytes = await pdfDoc.save(); const pdfBytes = await pdfDoc.save();
const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) }).promise; const pdfjsDoc = await getPDFDocument({ data: new Uint8Array(pdfBytes) })
.promise;
const numPages = pdfjsDoc.numPages; const numPages = pdfjsDoc.numPages;
const newPages: PageData[] = []; const newPages: PageData[] = [];
@@ -802,13 +896,18 @@ async function handleInsertPdf(e: Event) {
// Update UI if card exists // Update UI if card exists
const pagesContainer = document.getElementById('pages-container'); const pagesContainer = document.getElementById('pages-container');
const card = pagesContainer?.querySelector(`div[data-page-index="${globalIndex}"]`); const card = pagesContainer?.querySelector(
`div[data-page-index="${globalIndex}"]`
);
if (card) { if (card) {
const preview = card.querySelector('.bg-gray-700') || card.querySelector('.bg-white'); const preview =
card.querySelector('.bg-gray-700') ||
card.querySelector('.bg-white');
if (preview) { if (preview) {
// Re-create the preview content // Re-create the preview content
preview.innerHTML = ''; preview.innerHTML = '';
preview.className = 'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64'; preview.className =
'bg-white rounded mb-2 overflow-hidden w-full flex items-center justify-center relative h-36 sm:h-64';
const previewCanvas = canvas; const previewCanvas = canvas;
previewCanvas.className = 'max-w-full max-h-full object-contain'; previewCanvas.className = 'max-w-full max-h-full object-contain';
@@ -819,10 +918,13 @@ async function handleInsertPdf(e: Event) {
} }
} }
} }
} catch (e) { } catch (e) {
console.error('Failed to insert PDF:', e); console.error('Failed to insert PDF:', e);
showModal('Error', 'Failed to insert PDF. The file may be corrupted.', 'error'); showModal(
'Error',
'Failed to insert PDF. The file may be corrupted.',
'error'
);
} }
input.value = ''; input.value = '';
@@ -837,13 +939,15 @@ function renderSplitMarkers() {
const pagesContainer = document.getElementById('pages-container'); const pagesContainer = document.getElementById('pages-container');
if (!pagesContainer) return; if (!pagesContainer) return;
pagesContainer.querySelectorAll('.split-marker').forEach(m => m.remove()); pagesContainer.querySelectorAll('.split-marker').forEach((m) => m.remove());
Array.from(pagesContainer.children).forEach((cardEl, i) => { Array.from(pagesContainer.children).forEach((cardEl, i) => {
if (splitMarkers.has(i)) { if (splitMarkers.has(i)) {
const marker = document.createElement('div'); const marker = document.createElement('div');
marker.className = 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none'; marker.className =
marker.innerHTML = '<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>'; 'split-marker absolute -right-3 top-0 bottom-0 w-6 flex items-center justify-center z-20 pointer-events-none';
marker.innerHTML =
'<div class="h-full w-0.5 border-l-2 border-dashed border-blue-400"></div>';
(cardEl as HTMLElement).appendChild(marker); (cardEl as HTMLElement).appendChild(marker);
} }
}); });
@@ -881,7 +985,7 @@ function bulkRotate(delta: number) {
return; return;
} }
selectedPages.forEach(index => { selectedPages.forEach((index) => {
const pageData = allPages[index]; const pageData = allPages[index];
if (pageData) { if (pageData) {
// Update state // Update state
@@ -890,7 +994,9 @@ function bulkRotate(delta: number) {
// Update DOM immediately if it exists // Update DOM immediately if it exists
const pagesContainer = document.getElementById('pages-container'); const pagesContainer = document.getElementById('pages-container');
const card = pagesContainer?.querySelector(`div[data-page-index="${index}"]`); const card = pagesContainer?.querySelector(
`div[data-page-index="${index}"]`
);
if (card) { if (card) {
const canvas = card.querySelector('canvas'); const canvas = card.querySelector('canvas');
if (canvas) { if (canvas) {
@@ -911,7 +1017,7 @@ function bulkDelete() {
return; return;
} }
const indices = Array.from(selectedPages).sort((a, b) => b - a); const indices = Array.from(selectedPages).sort((a, b) => b - a);
indices.forEach(index => allPages.splice(index, 1)); indices.forEach((index) => allPages.splice(index, 1));
selectedPages.clear(); selectedPages.clear();
if (allPages.length === 0) { if (allPages.length === 0) {
@@ -928,7 +1034,7 @@ function bulkDuplicate() {
return; return;
} }
const indices = Array.from(selectedPages).sort((a, b) => b - a); const indices = Array.from(selectedPages).sort((a, b) => b - a);
indices.forEach(index => { indices.forEach((index) => {
duplicatePage(index); duplicatePage(index);
}); });
selectedPages.clear(); selectedPages.clear();
@@ -937,11 +1043,15 @@ function bulkDuplicate() {
function bulkSplit() { function bulkSplit() {
if (selectedPages.size === 0) { if (selectedPages.size === 0) {
showModal('No Selection', 'Please select pages to mark for splitting.', 'info'); showModal(
'No Selection',
'Please select pages to mark for splitting.',
'info'
);
return; return;
} }
const indices = Array.from(selectedPages); const indices = Array.from(selectedPages);
indices.forEach(index => { indices.forEach((index) => {
if (!splitMarkers.has(index)) { if (!splitMarkers.has(index)) {
splitMarkers.add(index); splitMarkers.add(index);
} }
@@ -951,7 +1061,6 @@ function bulkSplit() {
updatePageDisplay(); updatePageDisplay();
} }
async function downloadAll() { async function downloadAll() {
if (allPages.length === 0) { if (allPages.length === 0) {
showModal('No Pages', 'Please upload PDFs first.', 'info'); showModal('No Pages', 'Please upload PDFs first.', 'info');
@@ -993,24 +1102,63 @@ async function downloadSplitPdfs() {
segments.push(currentSegment); segments.push(currentSegment);
} }
// Create PDFs for each segment
for (let segIndex = 0; segIndex < segments.length; segIndex++) { for (let segIndex = 0; segIndex < segments.length; segIndex++) {
const segment = segments[segIndex]; const segment = segments[segIndex];
const newPdf = await PDFLibDocument.create(); const newPdf = await PDFLibDocument.create();
const segSpecs: (
| {
type: 'pdf';
pdfDoc: PDFLibDocument;
originalPageIndex: number;
rotation: number;
}
| { type: 'blank' }
)[] = [];
for (const index of segment) { for (const index of segment) {
const pageData = allPages[index]; const pageData = allPages[index];
if (!pageData) { if (!pageData) continue;
console.warn(`Page data missing for index ${index}`);
continue;
}
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) { if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]); segSpecs.push({
const page = newPdf.addPage(copiedPage); type: 'pdf',
pdfDoc: pageData.pdfDoc,
originalPageIndex: pageData.originalPageIndex,
rotation: pageData.rotation,
});
} else {
segSpecs.push({ type: 'blank' });
}
}
if (pageData.rotation !== 0) { const docPageIndices = new Map<PDFLibDocument, number[]>();
for (const spec of segSpecs) {
if (spec.type === 'pdf') {
if (!docPageIndices.has(spec.pdfDoc)) {
docPageIndices.set(spec.pdfDoc, []);
}
docPageIndices.get(spec.pdfDoc)!.push(spec.originalPageIndex);
}
}
const copiedPagesMap = new Map<PDFLibDocument, PDFPage[]>();
for (const [doc, pageIdxs] of Array.from(docPageIndices)) {
const copied = await newPdf.copyPages(doc, pageIdxs);
copiedPagesMap.set(doc, copied);
}
const docConsumeIndex = new Map<PDFLibDocument, number>();
docPageIndices.forEach((_, doc) => docConsumeIndex.set(doc, 0));
for (const spec of segSpecs) {
if (spec.type === 'pdf') {
const idx = docConsumeIndex.get(spec.pdfDoc)!;
const copiedPage = copiedPagesMap.get(spec.pdfDoc)![idx];
docConsumeIndex.set(spec.pdfDoc, idx + 1);
const page = newPdf.addPage(copiedPage);
if (spec.rotation !== 0) {
const currentRotation = page.getRotation().angle; const currentRotation = page.getRotation().angle;
page.setRotation(degrees(currentRotation + pageData.rotation)); page.setRotation(degrees(currentRotation + spec.rotation));
} }
} else { } else {
newPdf.addPage([595, 842]); newPdf.addPage([595, 842]);
@@ -1025,7 +1173,11 @@ async function downloadSplitPdfs() {
const zipBlob = await zip.generateAsync({ type: 'blob' }); const zipBlob = await zip.generateAsync({ type: 'blob' });
downloadFile(zipBlob, 'split-documents.zip'); downloadFile(zipBlob, 'split-documents.zip');
showModal('Success', `Downloaded ${segments.length} PDF files in a ZIP archive.`, 'success'); showModal(
'Success',
`Downloaded ${segments.length} PDF files in a ZIP archive.`,
'success'
);
} catch (e) { } catch (e) {
console.error('Failed to create split PDFs:', e); console.error('Failed to create split PDFs:', e);
showModal('Error', 'Failed to create split PDFs.', 'error'); showModal('Error', 'Failed to create split PDFs.', 'error');
@@ -1038,20 +1190,59 @@ async function downloadPagesAsPdf(indices: number[], filename: string) {
try { try {
const newPdf = await PDFLibDocument.create(); const newPdf = await PDFLibDocument.create();
const pageSpecs: (
| {
type: 'pdf';
pdfDoc: PDFLibDocument;
originalPageIndex: number;
rotation: number;
}
| { type: 'blank' }
)[] = [];
for (const index of indices) { for (const index of indices) {
const pageData = allPages[index]; const pageData = allPages[index];
if (!pageData) { if (!pageData) continue;
console.warn(`Page data missing for index ${index}`);
continue;
}
if (pageData.pdfDoc && pageData.originalPageIndex >= 0) { if (pageData.pdfDoc && pageData.originalPageIndex >= 0) {
// Copy page from original PDF pageSpecs.push({
const [copiedPage] = await newPdf.copyPages(pageData.pdfDoc, [pageData.originalPageIndex]); type: 'pdf',
const page = newPdf.addPage(copiedPage); pdfDoc: pageData.pdfDoc,
originalPageIndex: pageData.originalPageIndex,
rotation: pageData.rotation,
});
} else {
pageSpecs.push({ type: 'blank' });
}
}
if (pageData.rotation !== 0) { const docPageIndices = new Map<PDFLibDocument, number[]>();
for (const spec of pageSpecs) {
if (spec.type === 'pdf') {
if (!docPageIndices.has(spec.pdfDoc)) {
docPageIndices.set(spec.pdfDoc, []);
}
docPageIndices.get(spec.pdfDoc)!.push(spec.originalPageIndex);
}
}
const copiedPagesMap = new Map<PDFLibDocument, PDFPage[]>();
for (const [doc, pageIdxs] of Array.from(docPageIndices)) {
const copied = await newPdf.copyPages(doc, pageIdxs);
copiedPagesMap.set(doc, copied);
}
const docConsumeIndex = new Map<PDFLibDocument, number>();
docPageIndices.forEach((_, doc) => docConsumeIndex.set(doc, 0));
for (const spec of pageSpecs) {
if (spec.type === 'pdf') {
const idx = docConsumeIndex.get(spec.pdfDoc)!;
const copiedPage = copiedPagesMap.get(spec.pdfDoc)![idx];
docConsumeIndex.set(spec.pdfDoc, idx + 1);
const page = newPdf.addPage(copiedPage);
if (spec.rotation !== 0) {
const currentRotation = page.getRotation().angle; const currentRotation = page.getRotation().angle;
page.setRotation(degrees(currentRotation + pageData.rotation)); page.setRotation(degrees(currentRotation + spec.rotation));
} }
} else { } else {
newPdf.addPage([595, 842]); newPdf.addPage([595, 842]);
@@ -1059,7 +1250,9 @@ async function downloadPagesAsPdf(indices: number[], filename: string) {
} }
const pdfBytes = await newPdf.save(); const pdfBytes = await newPdf.save();
const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }); const blob = new Blob([new Uint8Array(pdfBytes)], {
type: 'application/pdf',
});
downloadFile(blob, filename); downloadFile(blob, filename);
showModal('Success', 'PDF downloaded successfully.', 'success'); showModal('Success', 'PDF downloaded successfully.', 'success');
@@ -1101,18 +1294,28 @@ function updatePageDisplay() {
// Update index-dependent attributes // Update index-dependent attributes
card.dataset.pageIndex = index.toString(); card.dataset.pageIndex = index.toString();
const info = card.querySelector('.text-xs.text-gray-400.text-center.mb-2'); const info = card.querySelector(
'.text-xs.text-gray-400.text-center.mb-2'
);
if (info) info.textContent = `Page ${index + 1} `; if (info) info.textContent = `Page ${index + 1} `;
// Update selection state // Update selection state
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]'); const selectBtn = card.querySelector(
'button[class*="absolute top-2 right-2"]'
);
if (selectBtn) { if (selectBtn) {
if (selectedPages.has(index)) { if (selectedPages.has(index)) {
card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500'); card.classList.add('border-indigo-500', 'ring-2', 'ring-indigo-500');
selectBtn.innerHTML = '<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>'; selectBtn.innerHTML =
'<i data-lucide="check-square" class="w-4 h-4 text-indigo-400"></i>';
} else { } else {
card.classList.remove('border-indigo-500', 'ring-2', 'ring-indigo-500'); card.classList.remove(
selectBtn.innerHTML = '<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>'; 'border-indigo-500',
'ring-2',
'ring-indigo-500'
);
selectBtn.innerHTML =
'<i data-lucide="square" class="w-4 h-4 text-gray-200"></i>';
} }
// Update click handler to use new index // Update click handler to use new index
(selectBtn as HTMLElement).onclick = (e) => { (selectBtn as HTMLElement).onclick = (e) => {
@@ -1128,17 +1331,47 @@ function updatePageDisplay() {
} }
// Update action buttons // Update action buttons
const actionsInner = card.querySelector('.flex.items-center.gap-1.bg-gray-900\\/90'); const actionsInner = card.querySelector(
'.flex.items-center.gap-1.bg-gray-900\\/90'
);
if (actionsInner) { if (actionsInner) {
const buttons = actionsInner.querySelectorAll('button'); const buttons = actionsInner.querySelectorAll('button');
if (buttons[0]) (buttons[0] as HTMLElement).onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); }; if (buttons[0])
if (buttons[1]) (buttons[1] as HTMLElement).onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); }; (buttons[0] as HTMLElement).onclick = (e) => {
if (buttons[2]) (buttons[2] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); duplicatePage(index); }; e.stopPropagation();
if (buttons[3]) (buttons[3] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); insertPdfAfter(index); }; rotatePage(index, -90);
if (buttons[4]) (buttons[4] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); toggleSplitMarker(index); renderSplitMarkers(); }; };
if (buttons[5]) (buttons[5] as HTMLElement).onclick = (e) => { e.stopPropagation(); snapshot(); deletePage(index); }; if (buttons[1])
(buttons[1] as HTMLElement).onclick = (e) => {
e.stopPropagation();
rotatePage(index, 90);
};
if (buttons[2])
(buttons[2] as HTMLElement).onclick = (e) => {
e.stopPropagation();
snapshot();
duplicatePage(index);
};
if (buttons[3])
(buttons[3] as HTMLElement).onclick = (e) => {
e.stopPropagation();
snapshot();
insertPdfAfter(index);
};
if (buttons[4])
(buttons[4] as HTMLElement).onclick = (e) => {
e.stopPropagation();
snapshot();
toggleSplitMarker(index);
renderSplitMarkers();
};
if (buttons[5])
(buttons[5] as HTMLElement).onclick = (e) => {
e.stopPropagation();
snapshot();
deletePage(index);
};
} }
} else { } else {
// Element doesn't exist, create it // Element doesn't exist, create it
card = createPageElement(pageData.canvas, index); card = createPageElement(pageData.canvas, index);
@@ -1179,7 +1412,9 @@ function updatePageNumbers() {
// We need to find the buttons and update their onclick handlers // We need to find the buttons and update their onclick handlers
// This is necessary because the original handlers captured the old index // This is necessary because the original handlers captured the old index
const selectBtn = card.querySelector('button[class*="absolute top-2 right-2"]') as HTMLButtonElement; const selectBtn = card.querySelector(
'button[class*="absolute top-2 right-2"]'
) as HTMLButtonElement;
if (selectBtn) { if (selectBtn) {
selectBtn.onclick = (e) => { selectBtn.onclick = (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -1187,16 +1422,47 @@ function updatePageNumbers() {
}; };
} }
const actionsInner = card.querySelector('.flex.items-center.gap-1.bg-gray-900\\/90'); const actionsInner = card.querySelector(
'.flex.items-center.gap-1.bg-gray-900\\/90'
);
if (actionsInner) { if (actionsInner) {
const buttons = actionsInner.querySelectorAll('button'); const buttons = actionsInner.querySelectorAll('button');
// Order: Rotate Left, Rotate Right, Duplicate, Insert, Split, Delete // Order: Rotate Left, Rotate Right, Duplicate, Insert, Split, Delete
if (buttons[0]) buttons[0].onclick = (e) => { e.stopPropagation(); rotatePage(index, -90); }; if (buttons[0])
if (buttons[1]) buttons[1].onclick = (e) => { e.stopPropagation(); rotatePage(index, 90); }; buttons[0].onclick = (e) => {
if (buttons[2]) buttons[2].onclick = (e) => { e.stopPropagation(); snapshot(); duplicatePage(index); }; e.stopPropagation();
if (buttons[3]) buttons[3].onclick = (e) => { e.stopPropagation(); snapshot(); insertPdfAfter(index); }; rotatePage(index, -90);
if (buttons[4]) buttons[4].onclick = (e) => { e.stopPropagation(); snapshot(); toggleSplitMarker(index); renderSplitMarkers(); }; };
if (buttons[5]) buttons[5].onclick = (e) => { e.stopPropagation(); snapshot(); deletePage(index); }; if (buttons[1])
buttons[1].onclick = (e) => {
e.stopPropagation();
rotatePage(index, 90);
};
if (buttons[2])
buttons[2].onclick = (e) => {
e.stopPropagation();
snapshot();
duplicatePage(index);
};
if (buttons[3])
buttons[3].onclick = (e) => {
e.stopPropagation();
snapshot();
insertPdfAfter(index);
};
if (buttons[4])
buttons[4].onclick = (e) => {
e.stopPropagation();
snapshot();
toggleSplitMarker(index);
renderSplitMarkers();
};
if (buttons[5])
buttons[5].onclick = (e) => {
e.stopPropagation();
snapshot();
deletePage(index);
};
} }
}); });
} }
+17 -10
View File
@@ -2,7 +2,11 @@ import { showAlert } from '../ui.js';
import { tesseractLanguages } from '../config/tesseract-languages.js'; import { tesseractLanguages } from '../config/tesseract-languages.js';
import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor'; import { createWorkflowEditor, updateNodeDisplay } from '../workflow/editor';
import { executeWorkflow } from '../workflow/engine'; import { executeWorkflow } from '../workflow/engine';
import { nodeRegistry, getNodesByCategory } from '../workflow/nodes/registry'; import {
nodeRegistry,
getNodesByCategory,
createNodeByType,
} from '../workflow/nodes/registry';
import type { BaseWorkflowNode } from '../workflow/nodes/base-node'; import type { BaseWorkflowNode } from '../workflow/nodes/base-node';
import type { WorkflowEditor } from '../workflow/editor'; import type { WorkflowEditor } from '../workflow/editor';
import { import {
@@ -43,6 +47,7 @@ import {
let workflowEditor: WorkflowEditor | null = null; let workflowEditor: WorkflowEditor | null = null;
let selectedNodeId: string | null = null; let selectedNodeId: string | null = null;
let deleteNodeHandler: EventListener | null = null;
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializePage); document.addEventListener('DOMContentLoaded', initializePage);
@@ -245,10 +250,14 @@ async function initializePage() {
} }
}); });
document.addEventListener('wf-delete-node', ((e: CustomEvent) => { if (deleteNodeHandler) {
document.removeEventListener('wf-delete-node', deleteNodeHandler);
}
deleteNodeHandler = ((e: CustomEvent) => {
const nodeId = e.detail?.nodeId; const nodeId = e.detail?.nodeId;
if (nodeId) deleteNodeById(nodeId); if (nodeId) deleteNodeById(nodeId);
}) as EventListener); }) as EventListener;
document.addEventListener('wf-delete-node', deleteNodeHandler);
} }
async function deleteNodeById(nodeId: string) { async function deleteNodeById(nodeId: string) {
@@ -539,14 +548,12 @@ async function addNodeToCanvas(
if (!workflowEditor) return; if (!workflowEditor) return;
const { editor, area } = workflowEditor; const { editor, area } = workflowEditor;
const entry = nodeRegistry[type];
if (!entry) {
console.error('Node type not found in registry:', type);
return;
}
try { try {
const node = entry.factory(); const node = createNodeByType(type);
if (!node) {
console.error('Node type not found in registry:', type);
return;
}
await editor.addNode(node); await editor.addNode(node);
const pos = position || getCanvasCenter(area); const pos = position || getCanvasCenter(area);
+2
View File
@@ -138,6 +138,7 @@ const init = async () => {
}; };
const toolTranslationKeys: Record<string, string> = { const toolTranslationKeys: Record<string, string> = {
'PDF Workflow Builder': 'tools:pdfWorkflow',
'PDF Multi Tool': 'tools:pdfMultiTool', 'PDF Multi Tool': 'tools:pdfMultiTool',
'Merge PDF': 'tools:mergePdf', 'Merge PDF': 'tools:mergePdf',
'Split PDF': 'tools:splitPdf', 'Split PDF': 'tools:splitPdf',
@@ -158,6 +159,7 @@ const init = async () => {
'Background Color': 'tools:backgroundColor', 'Background Color': 'tools:backgroundColor',
'Change Text Color': 'tools:changeTextColor', 'Change Text Color': 'tools:changeTextColor',
'Add Stamps': 'tools:addStamps', 'Add Stamps': 'tools:addStamps',
'Bates Numbering': 'tools:batesNumbering',
'Remove Annotations': 'tools:removeAnnotations', 'Remove Annotations': 'tools:removeAnnotations',
'PDF Form Filler': 'tools:pdfFormFiller', 'PDF Form Filler': 'tools:pdfFormFiller',
'Create PDF Form': 'tools:createPdfForm', 'Create PDF Form': 'tools:createPdfForm',
+6 -2
View File
@@ -1,6 +1,10 @@
import { PDFDocument as PDFLibDocument } from 'pdf-lib'; import { PDFDocument as PDFLibDocument } from 'pdf-lib';
export interface AddWatermarkState { export interface AddWatermarkState {
file: File | null; file: File | null;
pdfDoc: PDFLibDocument | null; pdfDoc: PDFLibDocument | null;
pdfBytes: Uint8Array | null;
previewCanvas: HTMLCanvasElement | null;
watermarkX: number; // 0–1, percentage from left
watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF)
} }
+12 -15
View File
@@ -83,25 +83,22 @@ export async function performCondenseCompression(
try { try {
const result = await pymupdf.compressPdf(fileBlob, options); const result = await pymupdf.compressPdf(fileBlob, options);
return result; return result;
} catch (error: any) { } catch {
const errorMessage = error?.message || String(error); const fallbackOptions = {
if ( ...options,
errorMessage.includes('PatternType') || images: {
errorMessage.includes('pattern') ...options.images,
) { enabled: false,
const fallbackOptions = { },
...options, };
images: {
...options.images,
enabled: false,
},
};
try {
const result = await pymupdf.compressPdf(fileBlob, fallbackOptions); const result = await pymupdf.compressPdf(fileBlob, fallbackOptions);
return { ...result, usedFallback: true }; return { ...result, usedFallback: true };
} catch (fallbackError: any) {
const msg = fallbackError?.message || String(fallbackError);
throw new Error(`PDF compression failed: ${msg}`);
} }
throw new Error(`PDF compression failed: ${errorMessage}`);
} }
} }
+72 -14
View File
@@ -187,6 +187,9 @@ export interface TextWatermarkOptions {
color: { r: number; g: number; b: number }; color: { r: number; g: number; b: number };
opacity: number; opacity: number;
angle: number; angle: number;
x?: number;
y?: number;
pageIndices?: number[];
} }
export async function addTextWatermark( export async function addTextWatermark(
@@ -194,19 +197,60 @@ export async function addTextWatermark(
options: TextWatermarkOptions options: TextWatermarkOptions
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.load(pdfBytes); const pdfDoc = await PDFDocument.load(pdfBytes);
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to create canvas context');
const dpr = 2;
const colorR = Math.round(options.color.r * 255);
const colorG = Math.round(options.color.g * 255);
const colorB = Math.round(options.color.b * 255);
const fontStr = `bold ${options.fontSize * dpr}px "Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans Arabic", Arial, sans-serif`;
ctx.font = fontStr;
const metrics = ctx.measureText(options.text);
canvas.width = Math.ceil(metrics.width) + 4;
canvas.height = Math.ceil(options.fontSize * dpr * 1.4);
ctx.font = fontStr;
ctx.fillStyle = `rgb(${colorR}, ${colorG}, ${colorB})`;
ctx.textBaseline = 'middle';
ctx.fillText(options.text, 2, canvas.height / 2);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))),
'image/png'
);
});
const imageBytes = new Uint8Array(await blob.arrayBuffer());
const image = await pdfDoc.embedPng(imageBytes);
const pages = pdfDoc.getPages(); const pages = pdfDoc.getPages();
const posX = options.x ?? 0.5;
const posY = options.y ?? 0.5;
const imgWidth = image.width / dpr;
const imgHeight = image.height / dpr;
for (const page of pages) { const rad = (options.angle * Math.PI) / 180;
const halfW = imgWidth / 2;
const halfH = imgHeight / 2;
const targetIndices = options.pageIndices ?? pages.map((_, i) => i);
for (const idx of targetIndices) {
const page = pages[idx];
if (!page) continue;
const { width, height } = page.getSize(); const { width, height } = page.getSize();
const textWidth = font.widthOfTextAtSize(options.text, options.fontSize); const cx = posX * width;
const cy = posY * height;
page.drawText(options.text, { page.drawImage(image, {
x: (width - textWidth) / 2, x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
y: height / 2, y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
font, width: imgWidth,
size: options.fontSize, height: imgHeight,
color: rgb(options.color.r, options.color.g, options.color.b),
opacity: options.opacity, opacity: options.opacity,
rotate: degrees(options.angle), rotate: degrees(options.angle),
}); });
@@ -221,6 +265,9 @@ export interface ImageWatermarkOptions {
opacity: number; opacity: number;
angle: number; angle: number;
scale: number; scale: number;
x?: number;
y?: number;
pageIndices?: number[];
} }
export async function addImageWatermark( export async function addImageWatermark(
@@ -233,15 +280,26 @@ export async function addImageWatermark(
? await pdfDoc.embedPng(options.imageBytes) ? await pdfDoc.embedPng(options.imageBytes)
: await pdfDoc.embedJpg(options.imageBytes); : await pdfDoc.embedJpg(options.imageBytes);
const pages = pdfDoc.getPages(); const pages = pdfDoc.getPages();
const posX = options.x ?? 0.5;
const posY = options.y ?? 0.5;
for (const page of pages) { const imgWidth = image.width * options.scale;
const imgHeight = image.height * options.scale;
const rad = (options.angle * Math.PI) / 180;
const halfW = imgWidth / 2;
const halfH = imgHeight / 2;
const targetIndices = options.pageIndices ?? pages.map((_, i) => i);
for (const idx of targetIndices) {
const page = pages[idx];
if (!page) continue;
const { width, height } = page.getSize(); const { width, height } = page.getSize();
const imgWidth = image.width * options.scale; const cx = posX * width;
const imgHeight = image.height * options.scale; const cy = posY * height;
page.drawImage(image, { page.drawImage(image, {
x: (width - imgWidth) / 2, x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
y: (height - imgHeight) / 2, y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
width: imgWidth, width: imgWidth,
height: imgHeight, height: imgHeight,
opacity: options.opacity, opacity: options.opacity,
+51 -28
View File
@@ -103,7 +103,10 @@ export async function renderPageToCanvas(
canvas.height = viewport.height; canvas.height = viewport.height;
canvas.width = viewport.width; canvas.width = viewport.width;
const context = canvas.getContext('2d')!; const context = canvas.getContext('2d');
if (!context) {
throw new Error(`Failed to get 2D context for page ${pageNumber}`);
}
await page.render({ await page.render({
canvasContext: context, canvasContext: context,
@@ -115,12 +118,9 @@ export async function renderPageToCanvas(
} }
/** /**
* Renders a batch of pages in parallel * Renders a batch of pages
*/ */
async function renderPageBatch( async function renderPageBatch(tasks: PageTask[]): Promise<void> {
tasks: PageTask[],
onProgress?: (current: number, total: number) => void
): Promise<void> {
for (const task of tasks) { for (const task of tasks) {
try { try {
const canvas = await renderPageToCanvas( const canvas = await renderPageToCanvas(
@@ -147,6 +147,13 @@ async function renderPageBatch(
parent.insertBefore(wrapper, placeholder); parent.insertBefore(wrapper, placeholder);
parent.removeChild(placeholder); parent.removeChild(placeholder);
} else { } else {
const existingRendered = task.container.querySelector(
`[data-page-number="${task.pageNumber}"]:not([data-lazy-load="true"])`
);
if (existingRendered) {
continue;
}
const allChildren = Array.from( const allChildren = Array.from(
task.container.children task.container.children
) as HTMLElement[]; ) as HTMLElement[];
@@ -207,7 +214,7 @@ function setupLazyRendering(
task.placeholderElement = placeholder; task.placeholderElement = placeholder;
// Render this page immediately (not waiting for isRendering flag) // Render this page immediately (not waiting for isRendering flag)
renderPageBatch([task], config.onProgress) renderPageBatch([task])
.then(() => { .then(() => {
// Trigger callback after lazy load batch // Trigger callback after lazy load batch
if (config.onBatchComplete) { if (config.onBatchComplete) {
@@ -263,21 +270,19 @@ export async function renderPagesProgressively(
config: RenderConfig = {} config: RenderConfig = {}
): Promise<void> { ): Promise<void> {
const { const {
batchSize = 8, // Increased from 5 to 8 for faster initial render batchSize = 8,
useLazyLoading = true, useLazyLoading = true,
eagerLoadBatches = 2, // Eagerly load 1 batch ahead by default eagerLoadBatches = 2,
onProgress, onProgress,
onBatchComplete, onBatchComplete,
} = config; } = config;
const totalPages = pdfjsDoc.numPages; const totalPages = pdfjsDoc.numPages;
// Render more pages initially to reduce lazy loading issues
const initialRenderCount = useLazyLoading const initialRenderCount = useLazyLoading
? Math.min(20, totalPages) // Increased from 12 to 20 pages ? Math.min(20, totalPages)
: totalPages; : totalPages;
// CRITICAL FIX: Create placeholders for ALL pages first to maintain order
const placeholders: HTMLElement[] = []; const placeholders: HTMLElement[] = [];
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
const placeholder = createPlaceholder(i); const placeholder = createPlaceholder(i);
@@ -293,7 +298,7 @@ export async function renderPagesProgressively(
pageNumber: i, pageNumber: i,
pdfjsDoc, pdfjsDoc,
container, container,
scale: config.useLazyLoading ? 0.3 : 0.5, scale: useLazyLoading ? 0.3 : 0.5,
createWrapper, createWrapper,
placeholderElement: placeholders[i - 1], placeholderElement: placeholders[i - 1],
}); });
@@ -330,19 +335,24 @@ export async function renderPagesProgressively(
const batch = initialTasks.slice(i, i + batchSize); const batch = initialTasks.slice(i, i + batchSize);
await new Promise<void>((resolve) => { await new Promise<void>((resolve, reject) => {
requestIdleCallbackPolyfill(async () => { requestIdleCallbackPolyfill(() => {
await renderPageBatch(batch, onProgress); renderPageBatch(batch)
.then(() => {
if (onProgress) {
onProgress(
Math.min(i + batchSize, initialRenderCount),
totalPages
);
}
if (onProgress) { if (onBatchComplete) {
onProgress(Math.min(i + batchSize, initialRenderCount), totalPages); onBatchComplete();
} }
if (onBatchComplete) { resolve();
onBatchComplete(); })
} .catch(reject);
resolve();
}); });
}); });
} }
@@ -397,8 +407,11 @@ function renderEagerBatch(config: RenderConfig): void {
requestIdleCallbackPolyfill(async () => { requestIdleCallbackPolyfill(async () => {
if (config.shouldCancel?.()) return; if (config.shouldCancel?.()) return;
// Remove these tasks from pending since we're rendering them eagerly const tasksToRender = batch.filter((task) =>
batch.forEach((task) => { lazyLoadState.pendingTasksByPageNumber.has(task.pageNumber)
);
tasksToRender.forEach((task) => {
const placeholder = task.placeholderElement; const placeholder = task.placeholderElement;
if (placeholder && lazyLoadState.observer) { if (placeholder && lazyLoadState.observer) {
lazyLoadState.observer.unobserve(placeholder); lazyLoadState.observer.unobserve(placeholder);
@@ -407,7 +420,18 @@ function renderEagerBatch(config: RenderConfig): void {
} }
}); });
await renderPageBatch(batch, config.onProgress); if (tasksToRender.length === 0) {
lazyLoadState.nextEagerIndex = batchEnd;
const remainingBatches = Math.ceil(
(eagerLoadQueue.length - batchEnd) / batchSize
);
if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) {
renderEagerBatch(config);
}
return;
}
await renderPageBatch(tasksToRender);
if (config.onBatchComplete) { if (config.onBatchComplete) {
config.onBatchComplete(); config.onBatchComplete();
@@ -421,7 +445,6 @@ function renderEagerBatch(config: RenderConfig): void {
(eagerLoadQueue.length - batchEnd) / batchSize (eagerLoadQueue.length - batchEnd) / batchSize
); );
if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) { if (remainingBatches > 0 && remainingBatches < eagerLoadBatches) {
// Continue eager loading if we have more batches within the eager threshold
renderEagerBatch(config); renderEagerBatch(config);
} }
}); });
+1 -1
View File
@@ -9,7 +9,7 @@ interface WasmProviderConfig {
const STORAGE_KEY = 'bentopdf:wasm-providers'; const STORAGE_KEY = 'bentopdf:wasm-providers';
const CDN_DEFAULTS: Record<WasmPackage, string> = { const CDN_DEFAULTS: Record<WasmPackage, string> = {
pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/', pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/',
ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/', ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/',
cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/', cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/',
}; };
+174 -149
View File
@@ -8,7 +8,7 @@ import { LitPlugin, Presets as LitPresets } from '@retejs/lit-plugin';
import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin'; import type { ClassicScheme, LitArea2D } from '@retejs/lit-plugin';
import { DataflowEngine } from 'rete-engine'; import { DataflowEngine } from 'rete-engine';
import type { DataflowEngineScheme } from 'rete-engine'; import type { DataflowEngineScheme } from 'rete-engine';
import { html } from 'lit'; import { LitElement, html } from 'lit';
import type { BaseWorkflowNode } from './nodes/base-node'; import type { BaseWorkflowNode } from './nodes/base-node';
// @ts-ignore -- Vite ?inline import for injecting into Shadow DOM // @ts-ignore -- Vite ?inline import for injecting into Shadow DOM
import phosphorCSS from '@phosphor-icons/web/regular?inline'; import phosphorCSS from '@phosphor-icons/web/regular?inline';
@@ -49,6 +49,175 @@ function getStatusInfo(status: string, connected: boolean) {
}; };
} }
class WorkflowNodeElement extends LitElement {
static properties = {
data: { attribute: false },
emit: { attribute: false },
};
declare data: BaseWorkflowNode | undefined;
declare emit: ((data: unknown) => void) | undefined;
createRenderRoot(): HTMLElement | ShadowRoot {
return this;
}
render() {
if (!this.data) return html``;
const node = this.data;
const inputs = Object.entries(node.inputs || {});
const outputs = Object.entries(node.outputs || {});
const color = categoryColors[node.category] || '#6b7280';
const emitFn = this.emit;
return html`
<div
style="
position: relative; display: flex; flex-direction: column;
align-items: center; width: 280px;
"
>
${inputs.length > 0
? html`
<div
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-bottom: -7px;"
>
${inputs.map(([key, input]) =>
input
? html`
<div
style="display: flex; align-items: center; justify-content: center;"
>
<rete-ref
.data=${{
type: 'socket',
side: 'input',
key,
nodeId: node.id,
payload: input.socket,
}}
.emit=${emitFn}
></rete-ref>
</div>
`
: null
)}
</div>
`
: null}
<div
style="
background: #1f2937; border: 1px solid #374151;
border-radius: 12px; width: 100%; overflow: hidden;
"
>
<div
style="height: 3px; border-radius: 10px 10px 0 0; overflow: hidden;"
>
<div
data-wf="bar"
style="
height: 100%; width: 100%;
background: #6b7280; opacity: 0.25;
"
></div>
</div>
<div
style="padding: 6px 14px; display: flex; align-items: center; gap: 6px;"
>
<span
data-wf="dot"
style="
width: 7px; height: 7px; border-radius: 50%; background: #6b7280; flex-shrink: 0;
"
></span>
<span
data-wf="label"
style="font-size: 10px; color: #6b7280; font-weight: 500; flex: 1;"
>Not connected</span
>
<span
data-wf-delete="${node.id}"
style="
cursor: pointer; display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: 4px;
color: #6b7280; transition: all 0.15s;
"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</span>
</div>
<div style="height: 1px; background: #374151; margin: 0 14px;"></div>
<div
style="padding: 10px 14px 12px; display: flex; align-items: flex-start; gap: 10px;"
>
<i
class="ph ${node.icon}"
style="font-size: 18px; color: ${color}; flex-shrink: 0; margin-top: 1px; line-height: 1;"
></i>
<div style="flex: 1; min-width: 0;">
<div
style="font-size: 13px; font-weight: 600; color: #f3f4f6; line-height: 1.3;"
>
${node.label}
</div>
<div
style="font-size: 11px; color: #9ca3af; margin-top: 2px; line-height: 1.3;"
>
${node.description}
</div>
</div>
</div>
</div>
${outputs.length > 0
? html`
<div
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-top: -7px;"
>
${outputs.map(([key, output]) =>
output
? html`
<div
style="display: flex; align-items: center; justify-content: center;"
>
<rete-ref
.data=${{
type: 'socket',
side: 'output',
key,
nodeId: node.id,
payload: output.socket,
}}
.emit=${emitFn}
></rete-ref>
</div>
`
: null
)}
</div>
`
: null}
</div>
`;
}
}
if (!customElements.get('wf-node')) {
customElements.define('wf-node', WorkflowNodeElement);
}
export function updateNodeDisplay( export function updateNodeDisplay(
nodeId: string, nodeId: string,
editor: NodeEditor<ClassicScheme>, editor: NodeEditor<ClassicScheme>,
@@ -111,154 +280,10 @@ export async function createWorkflowEditor(
customize: { customize: {
node(data) { node(data) {
return ({ emit }: { emit: (data: unknown) => void }) => { return ({ emit }: { emit: (data: unknown) => void }) => {
const node = data.payload as BaseWorkflowNode; return html`<wf-node
const inputs = Object.entries(node.inputs || {}); .data=${data.payload}
const outputs = Object.entries(node.outputs || {}); .emit=${emit}
const color = categoryColors[node.category] || '#6b7280'; ></wf-node>`;
return html`
<div
style="
position: relative; display: flex; flex-direction: column;
align-items: center; width: 280px;
"
>
${inputs.length > 0
? html`
<div
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-bottom: -7px;"
>
${inputs.map(([key, input]) =>
input
? html`
<div
style="display: flex; align-items: center; justify-content: center;"
>
<rete-ref
.data=${{
type: 'socket',
side: 'input',
key,
nodeId: node.id,
payload: input.socket,
}}
.emit=${emit}
></rete-ref>
</div>
`
: null
)}
</div>
`
: null}
<div
style="
background: #1f2937; border: 1px solid #374151;
border-radius: 12px; width: 100%; overflow: hidden;
"
>
<div
style="height: 3px; border-radius: 10px 10px 0 0; overflow: hidden;"
>
<div
data-wf="bar"
style="
height: 100%; width: 100%;
background: #6b7280; opacity: 0.25;
"
></div>
</div>
<div
style="padding: 6px 14px; display: flex; align-items: center; gap: 6px;"
>
<span
data-wf="dot"
style="
width: 7px; height: 7px; border-radius: 50%; background: #6b7280; flex-shrink: 0;
"
></span>
<span
data-wf="label"
style="font-size: 10px; color: #6b7280; font-weight: 500; flex: 1;"
>Not connected</span
>
<span
data-wf-delete="${node.id}"
style="
cursor: pointer; display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; border-radius: 4px;
color: #6b7280; transition: all 0.15s;
"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</span>
</div>
<div
style="height: 1px; background: #374151; margin: 0 14px;"
></div>
<div
style="padding: 10px 14px 12px; display: flex; align-items: flex-start; gap: 10px;"
>
<i
class="ph ${node.icon}"
style="font-size: 18px; color: ${color}; flex-shrink: 0; margin-top: 1px; line-height: 1;"
></i>
<div style="flex: 1; min-width: 0;">
<div
style="font-size: 13px; font-weight: 600; color: #f3f4f6; line-height: 1.3;"
>
${node.label}
</div>
<div
style="font-size: 11px; color: #9ca3af; margin-top: 2px; line-height: 1.3;"
>
${node.description}
</div>
</div>
</div>
</div>
${outputs.length > 0
? html`
<div
style="display: flex; justify-content: center; gap: 8px; position: relative; z-index: 1; margin-top: -7px;"
>
${outputs.map(([key, output]) =>
output
? html`
<div
style="display: flex; align-items: center; justify-content: center;"
>
<rete-ref
.data=${{
type: 'socket',
side: 'output',
key,
nodeId: node.id,
payload: output.socket,
}}
.emit=${emit}
></rete-ref>
</div>
`
: null
)}
</div>
`
: null}
</div>
`;
}; };
}, },
socket() { socket() {
+1
View File
@@ -9,6 +9,7 @@ export abstract class BaseWorkflowNode extends ClassicPreset.Node {
width = 280; width = 280;
height = 140; height = 140;
execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle'; execStatus: 'idle' | 'running' | 'completed' | 'error' = 'idle';
nodeType: string = '';
constructor(label: string) { constructor(label: string) {
super(label); super(label);
+3 -1
View File
@@ -593,7 +593,9 @@ export const nodeRegistry: Record<string, NodeRegistryEntry> = {
export function createNodeByType(type: string): BaseWorkflowNode | null { export function createNodeByType(type: string): BaseWorkflowNode | null {
const entry = nodeRegistry[type]; const entry = nodeRegistry[type];
if (!entry) return null; if (!entry) return null;
return entry.factory(); const node = entry.factory();
node.nodeType = type;
return node;
} }
export function getNodesByCategory(): Record< export function getNodesByCategory(): Record<
+88 -2
View File
@@ -3,9 +3,10 @@ import { BaseWorkflowNode } from './base-node';
import { pdfSocket } from '../sockets'; import { pdfSocket } from '../sockets';
import type { SocketData } from '../types'; import type { SocketData } from '../types';
import { requirePdfInput, processBatch } from '../types'; import { requirePdfInput, processBatch } from '../types';
import { addTextWatermark } from '../../utils/pdf-operations'; import { addTextWatermark, parsePageRange } from '../../utils/pdf-operations';
import { PDFDocument } from 'pdf-lib'; import { PDFDocument } from 'pdf-lib';
import { hexToRgb } from '../../utils/helpers.js'; import { hexToRgb } from '../../utils/helpers.js';
import * as pdfjsLib from 'pdfjs-dist';
export class WatermarkNode extends BaseWorkflowNode { export class WatermarkNode extends BaseWorkflowNode {
readonly category = 'Edit & Annotate' as const; readonly category = 'Edit & Annotate' as const;
@@ -39,6 +40,18 @@ export class WatermarkNode extends BaseWorkflowNode {
'angle', 'angle',
new ClassicPreset.InputControl('number', { initial: -45 }) new ClassicPreset.InputControl('number', { initial: -45 })
); );
this.addControl(
'position',
new ClassicPreset.InputControl('text', { initial: 'center' })
);
this.addControl(
'pages',
new ClassicPreset.InputControl('text', { initial: 'all' })
);
this.addControl(
'flatten',
new ClassicPreset.InputControl('text', { initial: 'no' })
);
} }
async data( async data(
@@ -67,16 +80,89 @@ export class WatermarkNode extends BaseWorkflowNode {
const opacity = getNum('opacity', 30) / 100; const opacity = getNum('opacity', 30) / 100;
const angle = getNum('angle', -45); const angle = getNum('angle', -45);
const positionPresets: Record<string, { x: number; y: number }> = {
'top-left': { x: 0.15, y: 0.15 },
top: { x: 0.5, y: 0.15 },
'top-right': { x: 0.85, y: 0.15 },
left: { x: 0.15, y: 0.5 },
center: { x: 0.5, y: 0.5 },
right: { x: 0.85, y: 0.5 },
'bottom-left': { x: 0.15, y: 0.85 },
bottom: { x: 0.5, y: 0.85 },
'bottom-right': { x: 0.85, y: 0.85 },
};
const posKey = getText('position', 'center').trim().toLowerCase();
const { x, y } = positionPresets[posKey] ?? positionPresets['center'];
const pagesStr = getText('pages', 'all').trim().toLowerCase();
const shouldFlatten =
getText('flatten', 'no').trim().toLowerCase() === 'yes';
return { return {
pdf: await processBatch(pdfInputs, async (input) => { pdf: await processBatch(pdfInputs, async (input) => {
const resultBytes = await addTextWatermark(input.bytes, { const srcDoc = await PDFDocument.load(input.bytes);
const totalPages = srcDoc.getPageCount();
const pageIndices =
pagesStr === 'all' ? undefined : parsePageRange(pagesStr, totalPages);
let resultBytes = await addTextWatermark(input.bytes, {
text: watermarkText, text: watermarkText,
fontSize, fontSize,
color: { r: c.r, g: c.g, b: c.b }, color: { r: c.r, g: c.g, b: c.b },
opacity, opacity,
angle, angle,
x,
y: 1 - y,
pageIndices,
}); });
if (shouldFlatten) {
const watermarkedPdf = await pdfjsLib.getDocument({
data: resultBytes.slice(),
}).promise;
const flattenedDoc = await PDFDocument.create();
const renderScale = 2.5;
for (let i = 1; i <= watermarkedPdf.numPages; i++) {
const page = await watermarkedPdf.getPage(i);
const unscaledVP = page.getViewport({ scale: 1 });
const viewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d')!;
await page.render({ canvasContext: ctx, canvas, viewport }).promise;
const jpegBytes = await new Promise<ArrayBuffer>(
(resolve, reject) =>
canvas.toBlob(
(blob) =>
blob
? blob.arrayBuffer().then(resolve)
: reject(new Error(`Failed to rasterize page ${i}`)),
'image/jpeg',
0.92
)
);
const image = await flattenedDoc.embedJpg(jpegBytes);
const newPage = flattenedDoc.addPage([
unscaledVP.width,
unscaledVP.height,
]);
newPage.drawImage(image, {
x: 0,
y: 0,
width: unscaledVP.width,
height: unscaledVP.height,
});
}
resultBytes = new Uint8Array(await flattenedDoc.save());
}
const resultDoc = await PDFDocument.load(resultBytes); const resultDoc = await PDFDocument.load(resultBytes);
return { return {
+35 -14
View File
@@ -25,8 +25,7 @@ interface SerializedConnection {
} }
function getNodeType(node: BaseWorkflowNode): string | null { function getNodeType(node: BaseWorkflowNode): string | null {
const constructorName = node.constructor.name; return node.nodeType || null;
return constructorName || null;
} }
function serializeWorkflow( function serializeWorkflow(
@@ -79,6 +78,22 @@ async function deserializeWorkflow(
editor: NodeEditor<ClassicScheme>, editor: NodeEditor<ClassicScheme>,
area: AreaPlugin<ClassicScheme, AreaExtra> area: AreaPlugin<ClassicScheme, AreaExtra>
): Promise<void> { ): Promise<void> {
if (
!data ||
!Array.isArray((data as any).nodes) ||
!Array.isArray((data as any).connections)
) {
throw new Error(
'Invalid workflow file: missing nodes or connections array.'
);
}
if ((data as any).version !== WORKFLOW_VERSION) {
console.warn(
`Workflow version mismatch: expected ${WORKFLOW_VERSION}, got ${(data as any).version}. Attempting load anyway.`
);
}
for (const conn of editor.getConnections()) { for (const conn of editor.getConnections()) {
await editor.removeConnection(conn.id); await editor.removeConnection(conn.id);
} }
@@ -129,9 +144,6 @@ async function deserializeWorkflow(
if (skippedTypes.length > 0) { if (skippedTypes.length > 0) {
console.warn('Skipped unknown node types during load:', skippedTypes); console.warn('Skipped unknown node types during load:', skippedTypes);
throw new Error(
`Some nodes could not be loaded: ${skippedTypes.join(', ')}. They may have been removed or renamed.`
);
} }
} }
@@ -215,14 +227,11 @@ export function exportWorkflow(
const json = JSON.stringify(data, null, 2); const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' }); const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
try { const a = document.createElement('a');
const a = document.createElement('a'); a.href = url;
a.href = url; a.download = 'workflow.json';
a.download = 'workflow.json'; a.click();
a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
} finally {
URL.revokeObjectURL(url);
}
} }
export async function importWorkflow( export async function importWorkflow(
@@ -233,13 +242,16 @@ export async function importWorkflow(
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.json'; input.accept = '.json';
let settled = false;
input.onchange = async () => { input.onchange = async () => {
settled = true;
const file = input.files?.[0]; const file = input.files?.[0];
if (!file) { if (!file) {
resolve(); resolve();
return; return;
} }
try { try {
const text = await file.text(); const text = await file.text();
const data = JSON.parse(text) as SerializedWorkflow; const data = JSON.parse(text) as SerializedWorkflow;
@@ -250,6 +262,15 @@ export async function importWorkflow(
reject(new Error(`Failed to import workflow: ${message}`)); reject(new Error(`Failed to import workflow: ${message}`));
} }
}; };
const onFocus = () => {
window.removeEventListener('focus', onFocus);
setTimeout(() => {
if (!settled) resolve();
}, 300);
};
window.addEventListener('focus', onFocus);
input.click(); input.click();
}); });
} }
+378 -143
View File
@@ -158,171 +158,406 @@
</div> </div>
<div id="file-display-area" class="mt-4 space-y-2"></div> <div id="file-display-area" class="mt-4 space-y-2"></div>
</div>
</div>
<div id="options-panel" class="hidden mt-6 space-y-4"> <div id="editor-panel" class="hidden bg-gray-900 px-2 sm:px-4 py-4 sm:py-8">
<div class="flex gap-4 mb-4"> <div class="max-w-7xl mx-auto">
<label class="flex items-center gap-2 cursor-pointer"> <div
<input class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-0 sm:justify-between mb-4 sm:mb-6"
type="radio" >
name="watermark-type" <button
value="text" id="editor-back-btn"
checked class="flex items-center gap-2 text-indigo-400 hover:text-indigo-300 font-semibold text-sm"
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500" >
/> <i data-lucide="arrow-left" class="w-4 h-4"></i>
<span class="text-sm font-medium text-gray-300" <span>Change File</span>
>Text Watermark</span </button>
<div
id="editor-file-info"
class="text-xs sm:text-sm text-gray-400 truncate max-w-full sm:max-w-[60%]"
></div>
</div>
<div class="flex flex-col lg:flex-row gap-4 sm:gap-6">
<div class="flex-1 min-w-0 flex flex-col">
<div
class="bg-gray-800 rounded-xl border border-gray-700 p-2 sm:p-4 flex-1 flex flex-col"
>
<div class="flex items-center justify-between mb-2 sm:mb-3">
<span class="text-xs sm:text-sm font-medium text-gray-300"
>Preview</span
>
<div class="flex items-center gap-2">
<button
id="prev-page-btn"
disabled
class="p-1 rounded hover:bg-gray-700 text-gray-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</button>
<div class="flex items-center gap-1 text-xs text-gray-300">
<input
id="page-num-input"
type="number"
min="1"
value="1"
class="w-10 bg-gray-700 border border-gray-600 text-white rounded text-center text-xs py-0.5"
/>
<span>/ <span id="total-pages">1</span></span>
</div>
<button
id="next-page-btn"
disabled
class="p-1 rounded hover:bg-gray-700 text-gray-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</button>
</div>
<span class="text-xs text-gray-500 hidden sm:inline"
>Drag watermark to position</span
>
</div>
<div
id="preview-wrapper"
class="flex-1 min-h-[200px] lg:min-h-0 flex items-center justify-center bg-gray-900 rounded-lg overflow-hidden"
> >
</label> <div
<label class="flex items-center gap-2 cursor-pointer"> id="preview-container"
<input class="relative overflow-hidden select-none"
type="radio" style="touch-action: none"
name="watermark-type" >
value="image" <canvas id="preview-canvas"></canvas>
class="w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 focus:ring-indigo-500" <div
/> id="watermark-box"
<span class="text-sm font-medium text-gray-300" class="absolute pointer-events-auto"
>Image Watermark</span style="
> left: 50%;
</label> top: 50%;
transform: translate(-50%, -50%);
"
>
<div
class="relative border border-dashed border-indigo-400/60 cursor-grab active:cursor-grabbing"
>
<div id="watermark-overlay" class="whitespace-nowrap">
CONFIDENTIAL
</div>
<img id="image-watermark-overlay" class="hidden" alt="" />
<div
data-handle="nw"
class="resize-handle absolute -top-1.5 -left-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-nw-resize pointer-events-auto z-10"
></div>
<div
data-handle="ne"
class="resize-handle absolute -top-1.5 -right-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-ne-resize pointer-events-auto z-10"
></div>
<div
data-handle="sw"
class="resize-handle absolute -bottom-1.5 -left-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-sw-resize pointer-events-auto z-10"
></div>
<div
data-handle="se"
class="resize-handle absolute -bottom-1.5 -right-1.5 w-3 h-3 bg-white border-2 border-indigo-500 rounded-sm cursor-se-resize pointer-events-auto z-10"
></div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div id="text-watermark-options"> <div class="w-full lg:w-80 flex-shrink-0">
<div class="space-y-4"> <div
<div> class="bg-gray-800 rounded-xl border border-gray-700 p-3 sm:p-5 space-y-3 sm:space-y-5"
>
<div class="flex gap-2">
<button
id="type-text-btn"
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors"
>
Text
</button>
<button
id="type-image-btn"
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors"
>
Image
</button>
</div>
<div
class="flex items-center gap-2 bg-gray-900 rounded-lg px-3 py-2"
>
<input
type="checkbox"
id="apply-all-pages"
checked
class="w-4 h-4 accent-indigo-500 bg-gray-700 border-gray-600 rounded focus:ring-indigo-500"
/>
<label <label
for="watermark-text" for="apply-all-pages"
class="block mb-2 text-sm font-medium text-gray-300" class="text-sm text-gray-300 cursor-pointer"
>Watermark Text</label data-i18n="tools:addWatermark.applyToAllPages"
>Apply to all pages</label
>
</div>
<div id="page-range-section" class="space-y-1.5">
<label
for="page-range-input"
class="block text-sm font-medium text-gray-300"
>Page Range</label
> >
<input <input
type="text" type="text"
id="watermark-text" id="page-range-input"
placeholder="CONFIDENTIAL" value="all"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5" placeholder="all or 1-3, 5, 7-9"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm placeholder-gray-500"
/> />
<p class="text-xs text-gray-500">
Use "all" or specify pages, e.g. 1-3, 5, 7-9
</p>
</div>
<div id="text-watermark-options">
<div class="space-y-4">
<div>
<label
for="watermark-text"
class="block mb-1.5 text-sm font-medium text-gray-300"
>Text</label
>
<input
type="text"
id="watermark-text"
placeholder="CONFIDENTIAL"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label
for="font-size"
class="block mb-1.5 text-sm font-medium text-gray-300"
>Font Size</label
>
<input
type="number"
id="font-size"
value="72"
min="10"
max="200"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 text-sm"
/>
</div>
<div>
<label
for="text-color"
class="block mb-1.5 text-sm font-medium text-gray-300"
>Color</label
>
<input
type="color"
id="text-color"
value="#888888"
class=""
/>
</div>
</div>
<div>
<label
for="opacity-text"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Opacity: <span id="opacity-value-text">0.3</span>
</label>
<input
type="range"
id="opacity-text"
min="0.05"
max="1"
step="0.05"
value="0.3"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
<div>
<label
for="angle-text"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Angle: <span id="angle-value-text">-45</span>&deg;
</label>
<input
type="range"
id="angle-text"
min="-90"
max="90"
step="1"
value="-45"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
</div>
</div>
<div id="image-watermark-options" class="hidden">
<div class="space-y-4">
<div>
<label
for="image-watermark-input"
class="block mb-1.5 text-sm font-medium text-gray-300"
>Watermark Image</label
>
<input
type="file"
id="image-watermark-input"
accept="image/png, image/jpeg"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2 text-sm file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-indigo-600 file:text-white hover:file:bg-indigo-700"
/>
</div>
<div>
<label
for="image-scale"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Scale: <span id="image-scale-value">100</span>%
</label>
<input
type="range"
id="image-scale"
min="10"
max="200"
step="5"
value="100"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
<div>
<label
for="opacity-image"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Opacity: <span id="opacity-value-image">0.3</span>
</label>
<input
type="range"
id="opacity-image"
min="0.05"
max="1"
step="0.05"
value="0.3"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
<div>
<label
for="angle-image"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Angle: <span id="angle-value-image">0</span>&deg;
</label>
<input
type="range"
id="angle-image"
min="-90"
max="90"
step="1"
value="0"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
</div>
</div> </div>
<div class="grid grid-cols-2 gap-4">
<div>
<label
for="font-size"
class="block mb-2 text-sm font-medium text-gray-300"
>Font Size</label
>
<input
type="number"
id="font-size"
value="72"
min="10"
max="200"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5"
/>
</div>
<div>
<label
for="text-color"
class="block mb-2 text-sm font-medium text-gray-300"
>Color</label
>
<input
type="color"
id="text-color"
value="#888888"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label
for="opacity-text"
class="block mb-2 text-sm font-medium text-gray-300"
>Opacity: <span id="opacity-value-text">0.3</span></label
>
<input
type="range"
id="opacity-text"
min="0.1"
max="1"
step="0.1"
value="0.3"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
/>
</div>
<div>
<label
for="angle-text"
class="block mb-2 text-sm font-medium text-gray-300"
>Angle: <span id="angle-value-text">-45</span>°</label
>
<input
type="range"
id="angle-text"
min="-90"
max="90"
step="5"
value="-45"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
/>
</div>
</div>
</div>
</div>
<div id="image-watermark-options" class="hidden">
<div class="space-y-4">
<div> <div>
<label <label class="block mb-1.5 text-sm font-medium text-gray-300"
for="image-watermark-input" >Position</label
class="block mb-2 text-sm font-medium text-gray-300"
>Watermark Image (PNG/JPG)</label
> >
<div class="grid grid-cols-3 gap-1.5">
<button
data-pos="0.15,0.15"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Top Left
</button>
<button
data-pos="0.5,0.15"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Top
</button>
<button
data-pos="0.85,0.15"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Top Right
</button>
<button
data-pos="0.15,0.5"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Left
</button>
<button
data-pos="0.5,0.5"
class="pos-preset-btn py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-md transition-colors"
>
Center
</button>
<button
data-pos="0.85,0.5"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Right
</button>
<button
data-pos="0.15,0.85"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Bottom Left
</button>
<button
data-pos="0.5,0.85"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Bottom
</button>
<button
data-pos="0.85,0.85"
class="pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors"
>
Bottom Right
</button>
</div>
</div>
<div class="flex items-start gap-3 bg-gray-900 rounded-lg p-3">
<input <input
type="file" type="checkbox"
id="image-watermark-input" id="flatten-watermark"
accept="image/png, image/jpeg" class="mt-0.5 w-4 h-4 text-indigo-600 bg-gray-700 border-gray-600 rounded focus:ring-indigo-500"
class="w-full bg-gray-700 border border-gray-600 text-white rounded-lg p-2.5 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-indigo-600 file:text-white hover:file:bg-indigo-700"
/> />
</div>
<div class="grid grid-cols-2 gap-4">
<div> <div>
<label <label
for="opacity-image" for="flatten-watermark"
class="block mb-2 text-sm font-medium text-gray-300" class="text-sm font-medium text-gray-300 cursor-pointer"
>Opacity: <span id="opacity-value-image">0.3</span></label >Flatten watermark</label
> >
<input <p class="text-xs text-gray-500 mt-0.5">
type="range" Bakes the watermark into page pixels, making it
id="opacity-image" tamper-resistant. Text will no longer be selectable.
min="0.1" </p>
max="1"
step="0.1"
value="0.3"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
/>
</div>
<div>
<label
for="angle-image"
class="block mb-2 text-sm font-medium text-gray-300"
>Angle: <span id="angle-value-image">0</span>°</label
>
<input
type="range"
id="angle-image"
min="-90"
max="90"
step="5"
value="0"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer"
/>
</div> </div>
</div> </div>
<button id="process-btn" class="btn-gradient w-full">
Add Watermark
</button>
</div> </div>
</div> </div>
<button id="process-btn" class="btn-gradient w-full mt-4">
Add Watermark
</button>
</div> </div>
</div> </div>
</div> </div>
+1 -6
View File
@@ -182,12 +182,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Background Color</label >Background Color</label
> >
<input <input type="color" id="background-color" value="#FFFFCC" />
type="color"
id="background-color"
value="#FFFFCC"
class="w-full h-12 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
<button id="process-btn" class="btn-gradient w-full mt-4"> <button id="process-btn" class="btn-gradient w-full mt-4">
Change Background Color Change Background Color
+1 -6
View File
@@ -320,12 +320,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Color</label >Color</label
> >
<input <input type="color" id="text-color" value="#000000" />
type="color"
id="text-color"
value="#000000"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
</div> </div>
+2 -12
View File
@@ -217,12 +217,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Background Color</label >Background Color</label
> >
<input <input type="color" id="background-color" value="#FFFFFF" />
type="color"
id="background-color"
value="#FFFFFF"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
<div class="flex items-end"> <div class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
@@ -261,12 +256,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Separator Color</label >Separator Color</label
> >
<input <input type="color" id="separator-color" value="#000000" />
type="color"
id="separator-color"
value="#000000"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
</div> </div>
+1 -6
View File
@@ -477,12 +477,7 @@
class="block text-sm font-medium text-gray-300 mb-2" class="block text-sm font-medium text-gray-300 mb-2"
>Text Color</label >Text Color</label
> >
<input <input type="color" id="sig-text-color" value="#000000" />
type="color"
id="sig-text-color"
value="#000000"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
<div> <div>
<label <label
+1 -6
View File
@@ -296,12 +296,7 @@
>Background Color</label >Background Color</label
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<input <input type="color" id="background-color" value="#ffffff" />
type="color"
id="background-color"
value="#ffffff"
class="h-10 w-20 rounded border border-gray-600 bg-gray-700 cursor-pointer"
/>
<span class="text-sm text-gray-400" <span class="text-sm text-gray-400"
>Color for margins/padding</span >Color for margins/padding</span
> >
+1 -6
View File
@@ -202,12 +202,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Font Color</label >Font Color</label
> >
<input <input type="color" id="font-color" value="#000000" class="" />
type="color"
id="font-color"
value="#000000"
class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer"
/>
</div> </div>
</div> </div>
</div> </div>
+1 -6
View File
@@ -253,12 +253,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Border Color</label >Border Color</label
> >
<input <input type="color" id="border-color" value="#000000" />
type="color"
id="border-color"
value="#000000"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
</div> </div>
+1 -6
View File
@@ -219,12 +219,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Color</label >Color</label
> >
<input <input type="color" id="text-color" value="#000000" />
type="color"
id="text-color"
value="#000000"
class="w-full h-10 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
</div> </div>
+1 -6
View File
@@ -169,12 +169,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>New Text Color</label >New Text Color</label
> >
<input <input type="color" id="text-color-input" value="#0000FF" />
type="color"
id="text-color-input"
value="#0000FF"
class="w-full h-12 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
</div> </div>
<button id="process-btn" class="btn-gradient w-full mt-4"> <button id="process-btn" class="btn-gradient w-full mt-4">
Change Text Color Change Text Color
+1 -6
View File
@@ -250,12 +250,7 @@
class="block mb-2 text-sm font-medium text-gray-300" class="block mb-2 text-sm font-medium text-gray-300"
>Text Color</label >Text Color</label
> >
<input <input type="color" id="text-color" value="#000000" />
type="color"
id="text-color"
value="#000000"
class="w-full h-[42px] bg-gray-700 border border-gray-600 rounded-lg p-1 cursor-pointer"
/>
</div> </div>
<div> <div>
<label <label
+2 -2
View File
@@ -144,10 +144,10 @@
<span class="text-xs text-gray-500">Recommended:</span> <span class="text-xs text-gray-500">Recommended:</span>
<code <code
class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate" class="text-xs text-indigo-400 bg-gray-800 px-2 py-1 rounded flex-1 truncate"
>https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/</code >https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/</code
> >
<button <button
data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]4/" data-copy="https://cdn.jsdelivr.net/npm/@bentopdf/[email protected]6/"
class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors" class="copy-btn p-1.5 bg-gray-600 hover:bg-gray-500 rounded text-gray-300 hover:text-white transition-colors"
title="Copy to clipboard" title="Copy to clipboard"
> >
+9 -8
View File
@@ -14,19 +14,20 @@ import type { OutputBundle } from 'rollup';
const SUPPORTED_LANGUAGES = [ const SUPPORTED_LANGUAGES = [
'en', 'en',
'ar',
'be', 'be',
'da',
'de', 'de',
'es', 'es',
'fr',
'id',
'it',
'nl',
'pt',
'tr',
'vi',
'zh', 'zh',
'zh-TW', 'zh-TW',
'vi',
'it',
'id',
'tr',
'fr',
'pt',
'nl',
'da',
] as const; ] as const;
const LANG_REGEX = new RegExp( const LANG_REGEX = new RegExp(
`^/(${SUPPORTED_LANGUAGES.join('|')})(?:/(.*))?$` `^/(${SUPPORTED_LANGUAGES.join('|')})(?:/(.*))?$`